Compare commits

..

10 Commits

Author SHA1 Message Date
Gregor Vostrak
ee09afc4e8 add frontend to deactivate user 2025-03-10 14:06:02 +01:00
Gregor Vostrak
ab095f4a39 fetch tasks on project show page, fixes #253 2025-03-10 13:35:01 +01:00
Gregor Vostrak
09384d3517 hide total billable amounts from employees when employees_can_see_billable_rates is disabled 2025-03-09 14:12:42 +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
59 changed files with 2161 additions and 72 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

@@ -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;
@@ -21,9 +26,12 @@ use App\Models\Organization;
use App\Service\BillableRateService;
use App\Service\InvitationService;
use App\Service\MemberService;
use App\Service\UserService;
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 +71,7 @@ class MemberController extends Controller
* @throws OrganizationNeedsAtLeastOneOwner
* @throws OnlyOwnerCanChangeOwnership
* @throws ChangingRoleToPlaceholderIsNotAllowed
* @throws ChangingRoleOfPlaceholderIsNotAllowed
*
* @operationId updateMember
*/
@@ -105,7 +114,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 +125,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 +136,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): 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): void {
app(UserService::class)->assignOrganizationEntitiesToDifferentMember($organization, $user, $memberTo->user, $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 +181,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;
@@ -33,6 +34,8 @@ class DashboardController extends Controller
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
}
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return Inertia::render('Dashboard', [
'weeklyProjectOverview' => $weeklyProjectOverview,
'latestTasks' => $latestTasks,
@@ -41,7 +44,7 @@ class DashboardController extends Controller
'dailyTrackedHours' => $dailyTrackedHours,
'totalWeeklyTime' => $totalWeeklyTime,
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount,
'totalWeeklyBillableAmount' => $showBillableRate ? $totalWeeklyBillableAmount : null,
'weeklyHistory' => $weeklyHistory,
]);
}

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

@@ -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

@@ -123,6 +123,7 @@ class JetstreamServiceProvider extends ServiceProvider
'members:invite-placeholder',
'members:change-ownership',
'members:make-placeholder',
'members:merge-into',
'members:update',
'members:delete',
'billing',
@@ -174,6 +175,7 @@ class JetstreamServiceProvider extends ServiceProvider
'members:view',
'members:update',
'members:invite-placeholder',
'members:merge-into',
'reports:view',
'reports:create',
'reports:update',

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,104 @@
<?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;
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,6 +7,7 @@ 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;
@@ -75,6 +76,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 +84,9 @@ class MemberService
if ($oldRole === Role::Owner) {
throw new OrganizationNeedsAtLeastOneOwner;
}
if ($oldRole === Role::Placeholder) {
throw new ChangingRoleOfPlaceholderIsNotAllowed;
}
if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed;
}

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

@@ -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.',
],
];

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>
@@ -36,6 +39,23 @@ const props = defineProps<{
<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-white 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-white 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

@@ -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,6 +49,11 @@ async function invitePlaceholder(id: string) {
);
}
}
const userHasValidMailAddress = computed(() => {
return !props.member.email.endsWith('@solidtime-import.test');
})
</script>
<template>
@@ -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

@@ -12,7 +12,7 @@ type AggregatedGroupedData = GroupedData & {
type GroupedData = {
seconds: number;
cost: number;
cost: number | null;
description: string | null | undefined;
};
@@ -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

@@ -43,7 +43,7 @@ const props = defineProps<{
totalWeeklyBillableAmount: {
value: number;
currency: string;
};
} | null;
weeklyHistory: {
date: string;
duration: number;
@@ -199,10 +199,11 @@ const option = ref({
<StatCard
title="Billable Amount"
:value="
props.totalWeeklyBillableAmount ?
formatCents(
props.totalWeeklyBillableAmount.value,
getOrganizationCurrencyString()
)
) : '--'
" />
<ProjectsChartCard
:weekly-project-overview="

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);
@@ -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

@@ -27,7 +27,7 @@ import {
canUpdateOrganization,
canViewClients,
canViewMembers,
canViewProjects,
canViewProjects, canViewReport,
canViewTags,
} from '@/utils/permissions';
import { isBillingActivated } from '@/utils/billing';
@@ -118,14 +118,17 @@ const page = usePage<{
{
title: 'Overview',
route: 'reporting',
show: true
},
{
title: 'Detailed',
route: 'reporting.detailed',
show: true
},
{
title: 'Shared',
route: 'reporting.shared',
show: canViewReport()
},
]"
:current="

View File

@@ -45,7 +45,6 @@ async function createApiToken(){
(response) => {
createApiTokenForm.name = '';
displayingToken.value = true;
// @ts-expect-error temporary fix until openapi docs type is fixed
newToken.value = response.data.access_token;
}
);
@@ -117,7 +116,7 @@ const deleteApiTokenMutation = useMutation({
mutationFn: async (apiTokenId: string) => {
return await api.deleteApiToken(undefined, {
params: {
apiTokenId: apiTokenId,
apiToken: apiTokenId,
},
});
},
@@ -130,7 +129,7 @@ const revokeApiTokenMutation = useMutation({
mutationFn: async (apiTokenId: string) => {
return await api.revokeApiToken(undefined, {
params: {
apiTokenId: apiTokenId,
apiToken: apiTokenId,
},
});
},

View File

@@ -53,6 +53,7 @@ onMounted(() => {
if (canViewProjectMembers()) {
useProjectMembersStore().fetchProjectMembers(projectId);
}
useTasksStore().fetchTasks();
});
const showEditProjectModal = ref(false);

View File

@@ -464,10 +464,11 @@ const tableData = computed(() => {
<div
class="justify-end pr-6 flex items-center font-medium">
{{
aggregatedTableTimeEntries.cost ?
formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString()
)
) : '--'
}}
</div>
</div>

View File

@@ -58,7 +58,7 @@ import {
PaginationRoot,
} from 'radix-vue';
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
@@ -66,7 +66,7 @@ import type { ExportFormat } from '@/types/reporting';
import { useNotificationsStore } from '@/utils/notification';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
const startDate = useSessionStorage<string>(
@@ -98,6 +98,7 @@ function getFilterAttributes() {
};
const params = {
...defaultParams,
member_id: !canViewAllTimeEntries() ? getCurrentMembershipId() : undefined,
member_ids:
selectedMembers.value.length > 0
? selectedMembers.value

View File

@@ -5,9 +5,9 @@ const ApiTokenResource = z
.object({
id: z.string(),
name: z.string(),
revoked: z.string(),
scopes: z.string(),
created_at: z.union([z.string(), z.null()]),
revoked: z.boolean(),
scopes: z.array(z.string()),
created_at: z.string(),
expires_at: z.union([z.string(), z.null()]),
})
.passthrough();
@@ -15,7 +15,17 @@ const ApiTokenCollection = z.array(ApiTokenResource);
const ApiTokenStoreRequest = z
.object({ name: z.string().min(1).max(255) })
.passthrough();
const ApiTokenWithAccessTokenResource = z.string();
const ApiTokenWithAccessTokenResource = z
.object({
id: z.string(),
name: z.string(),
revoked: z.boolean(),
scopes: z.array(z.string()),
created_at: z.string(),
expires_at: z.union([z.string(), z.null()]),
access_token: z.string(),
})
.passthrough();
const ClientResource = z
.object({
id: z.string(),
@@ -63,6 +73,10 @@ const MemberUpdateRequest = z
.object({ role: Role, billable_rate: z.union([z.number(), z.null()]) })
.partial()
.passthrough();
const MemberMergeIntoRequest = z
.object({ member_id: z.string() })
.partial()
.passthrough();
const OrganizationResource = z
.object({
id: z.string(),
@@ -486,6 +500,7 @@ export const schemas = {
MemberResource,
Role,
MemberUpdateRequest,
MemberMergeIntoRequest,
OrganizationResource,
OrganizationUpdateRequest,
ProjectResource,
@@ -1160,6 +1175,71 @@ const endpoints = makeApi([
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/member/:member/merge-into',
alias: 'mergeMember',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z
.object({ member_id: z.string() })
.partial()
.passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'member',
type: 'Path',
schema: z.string(),
},
],
response: z.null(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/members',
@@ -1392,7 +1472,7 @@ const endpoints = makeApi([
{
method: 'post',
path: '/v1/organizations/:organization/members/:member/make-placeholder',
alias: 'v1.members.make-placeholder',
alias: 'makePlaceholder',
requestFormat: 'json',
parameters: [
{
@@ -3045,7 +3125,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
.object({
key: z.union([z.string(), z.null()]),
seconds: z.number().int(),
cost: z.number().int(),
cost: z.union([z.number(), z.null()]),
grouped_type: z.union([
z.string(),
z.null(),
@@ -3061,7 +3141,10 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
seconds: z
.number()
.int(),
cost: z.number().int(),
cost: z.union([
z.number(),
z.null(),
]),
grouped_type: z.null(),
grouped_data: z.null(),
})
@@ -3075,7 +3158,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
z.null(),
]),
seconds: z.number().int(),
cost: z.number().int(),
cost: z.union([z.number(), z.null()]),
})
.passthrough(),
})
@@ -3471,6 +3554,17 @@ Please note that the access token is only shown in this response and cannot be r
.object({ data: ApiTokenWithAccessTokenResource })
.passthrough(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
@@ -3495,18 +3589,29 @@ Please note that the access token is only shown in this response and cannot be r
},
{
method: 'delete',
path: '/v1/users/me/api-tokens/:apiTokenId',
path: '/v1/users/me/api-tokens/:apiToken',
alias: 'deleteApiToken',
requestFormat: 'json',
parameters: [
{
name: 'apiTokenId',
name: 'apiToken',
type: 'Path',
schema: z.string(),
},
],
response: z.null(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
@@ -3517,22 +3622,38 @@ Please note that the access token is only shown in this response and cannot be r
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'post',
path: '/v1/users/me/api-tokens/:apiTokenId/revoke',
path: '/v1/users/me/api-tokens/:apiToken/revoke',
alias: 'revokeApiToken',
requestFormat: 'json',
parameters: [
{
name: 'apiTokenId',
name: 'apiToken',
type: 'Path',
schema: z.string(),
},
],
response: z.null(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
@@ -3543,6 +3664,11 @@ Please note that the access token is only shown in this response and cannot be r
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{

View File

@@ -77,6 +77,14 @@ export function canDeleteMembers() {
return currentUserHasPermission('members:delete');
}
export function canMergeMembers() {
return currentUserHasPermission('members:merge-into');
}
export function canMakeMembersPlaceholders() {
return currentUserHasPermission('members:make-placeholder');
}
export function canInvitePlaceholderMembers() {
return currentUserHasPermission('members:invite-placeholder');
}
@@ -101,9 +109,16 @@ export function canManageBilling() {
return currentUserHasPermission('billing');
}
export function canViewReport() {
return currentUserHasPermission('reports:view');
}
export function canUpdateReport() {
return currentUserHasPermission('reports:update');
}
export function canDeleteReport() {
return currentUserHasPermission('reports:delete');
}
export function canViewAllTimeEntries() {
return currentUserHasPermission('time-entries:view:all');
}

View File

@@ -0,0 +1,4 @@
name,color,billable_rate,is_public,client,billable_default,estimated_time,archived_at
"Project for Big Company",,10001,false,"Big Company",true,,
"Project without Client",#ef5350,,false,,false,1000,
"Project (Archived)",#6a407f,,true,"Some client",true,0,2024-08-25T10:00:00Z
1 name color billable_rate is_public client billable_default estimated_time archived_at
2 Project for Big Company 10001 false Big Company true
3 Project without Client #ef5350 false false 1000
4 Project (Archived) #6a407f true Some client true 0 2024-08-25T10:00:00Z

View File

@@ -0,0 +1,3 @@
description,billable,client,project,tags,start,end,task,user_name,user_email
"","false","","Project without Client","Development, Backend","2024-03-04T09:23:52Z","2024-03-04T09:23:52Z","","Peter Tester","peter.test@email.test"
"Working hard","true","Big Company","Project for Big Company","","2024-03-04T09:23:00Z","2024-03-04T10:23:01Z","Task 1","Peter Tester","peter.test@email.test"
1 description billable client project tags start end task user_name user_email
2 false Project without Client Development, Backend 2024-03-04T09:23:52Z 2024-03-04T09:23:52Z Peter Tester peter.test@email.test
3 Working hard true Big Company Project for Big Company 2024-03-04T09:23:00Z 2024-03-04T10:23:01Z Task 1 Peter Tester peter.test@email.test

View File

@@ -0,0 +1,3 @@
Client Name,Address
Example Client,""
"\\ 🔥 Special characters """"""`!@#$%^&*()_+\-=\[\]{};':""\\|,.''<>\/?~ \\\",""
1 Client Name Address
2 Example Client
3 \\ 🔥 Special characters """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\

View File

@@ -0,0 +1,3 @@
Client,Project,Project Code,Start Date,End Date,Project Notes,Total Hours,Billable Hours,Billable Amount,Budget By,Budget,Budget Spent,Budget Remaining,Total Costs,Team Costs,Expenses
Example Client,Example Project,,"","",This is an example project to help you trial Harvest. You can track time to this project and see what insights you can get from our reports! Feel free to make any edits you want to this project or even delete it.,"20,01","20,01","2.001,0",Hours,"50,0","20,01","29,99","0,0","0,0","0,0"
"\\ 🔥 Special characters client """"""`!@#$%^&*()_+\-=\[\]{};':""\\|,.''<>\/?~ \\\","\\ 🔥 Special characters project """"""`!@#$%^&*()_+\-=\[\]{};':""\\|,.''<>\/?~ \\\",,"","",,"0,0","0,0","0,0",Hours,"0,0","0,0","50,0","0,0","0,0","0,0"
1 Client Project Project Code Start Date End Date Project Notes Total Hours Billable Hours Billable Amount Budget By Budget Budget Spent Budget Remaining Total Costs Team Costs Expenses
2 Example Client Example Project This is an example project to help you trial Harvest. You can track time to this project and see what insights you can get from our reports! Feel free to make any edits you want to this project or even delete it. 20,01 20,01 2.001,0 Hours 50,0 20,01 29,99 0,0 0,0 0,0
3 \\ 🔥 Special characters client """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\ \\ 🔥 Special characters project """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\ 0,0 0,0 0,0 Hours 0,0 0,0 50,0 0,0 0,0 0,0

View File

@@ -0,0 +1,3 @@
Date,Client,Project,Project Code,Task,Notes,Hours,Billable?,Invoiced?,Approved?,First Name,Last Name,Roles,Employee?,Billable Rate,Billable Amount,Cost Rate,Cost Amount,Currency,External Reference URL
2024-03-04,,Project without Client,,,"","20,0",No,No,No,Peter,Tester,,Yes,"100,0","2.000,0","0,0","0,0",Euro - EUR,
2024-03-04,Big Company,Project for Big Company,,Task 1,Working hard,"0,01",Yes,No,No,Peter,Tester,,Yes,"100,0","1,0","0,0","0,0",Euro - EUR,
1 Date Client Project Project Code Task Notes Hours Billable? Invoiced? Approved? First Name Last Name Roles Employee? Billable Rate Billable Amount Cost Rate Cost Amount Currency External Reference URL
2 2024-03-04 Project without Client 20,0 No No No Peter Tester Yes 100,0 2.000,0 0,0 0,0 Euro - EUR
3 2024-03-04 Big Company Project for Big Company Task 1 Working hard 0,01 Yes No No Peter Tester Yes 100,0 1,0 0,0 0,0 Euro - EUR

View File

@@ -51,6 +51,7 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
Route::delete('/members/{member}', [MemberController::class, 'destroy'])->name('destroy');
Route::post('/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');
Route::post('/members/{member}/make-placeholder', [MemberController::class, 'makePlaceholder'])->name('make-placeholder');
Route::post('member/{member}/merge-into', [MemberController::class, 'mergeInto'])->name('merge-into');
});
// User routes

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Console\Commands\Correction;
use App\Console\Commands\Correction\CorrectionPlaceholderMembersCommand;
use App\Enums\Role;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(CorrectionPlaceholderMembersCommand::class)]
#[UsesClass(CorrectionPlaceholderMembersCommand::class)]
class CorrectionPlaceholderMembersCommandTest extends TestCaseWithDatabase
{
public function test_sets_member_role_to_placeholder_if_user_is_placeholder(): void
{
// Arrange
$organization = Organization::factory()->create();
$user1 = User::factory()->placeholder()->create();
$member1 = Member::factory()->forOrganization($organization)->forUser($user1)->role(Role::Admin)->create();
$user2 = User::factory()->create();
$member2 = Member::factory()->forOrganization($organization)->forUser($user2)->role(Role::Admin)->create();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('correction:placeholder-members');
// Assert
$this->assertSame(Command::SUCCESS, $exitCode);
$output = Artisan::output();
$member1->refresh();
$this->assertSame(Role::Placeholder->value, $member1->role);
$member2->refresh();
$this->assertSame(Role::Admin->value, $member2->role);
$this->assertSame("Sets all members who belong to a placeholder user to role placeholder...\n".
'Set role of member (id='.$member1->getKey().") to placeholder\n", $output);
}
public function test_sets_member_role_to_placeholder_if_user_is_placeholder_dry_run(): void
{
// Arrange
$organization = Organization::factory()->create();
$user1 = User::factory()->placeholder()->create();
$member1 = Member::factory()->forOrganization($organization)->forUser($user1)->role(Role::Admin)->create();
$user2 = User::factory()->create();
$member2 = Member::factory()->forOrganization($organization)->forUser($user2)->role(Role::Admin)->create();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('correction:placeholder-members --dry-run');
// Assert
$this->assertSame(Command::SUCCESS, $exitCode);
$output = Artisan::output();
$member1->refresh();
$this->assertSame(Role::Admin->value, $member1->role);
$member2->refresh();
$this->assertSame(Role::Admin->value, $member2->role);
$this->assertSame("Sets all members who belong to a placeholder user to role placeholder...\n".
"Running in dry-run mode. Nothing will be saved to the database.\n".
'Set role of member (id='.$member1->getKey().") to placeholder\n", $output);
}
}

View File

@@ -194,6 +194,164 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('message', 'Only owner can change ownership');
}
public function test_update_member_fails_if_user_tries_to_change_the_role_of_a_placeholder(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:update',
]);
$user = User::factory()->placeholder()->create();
$member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Placeholder)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [
'role' => Role::Admin->value,
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'changing_role_of_placeholder_is_not_allowed',
'message' => 'Changing role of placeholder is not allowed',
]);
}
public function test_merge_into_fails_if_url_member_is_not_part_of_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:merge-into',
]);
$userSource = User::factory()->placeholder()->create();
$memberSource = Member::factory()->forUser($userSource)->role(Role::Placeholder)->create();
$userDestination = User::factory()->create();
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
'member_id' => $memberDestination->getKey(),
]);
// Assert
$response->assertForbidden();
}
public function test_merge_into_returns_validation_error_if_member_in_body_does_not_belong_to_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:merge-into',
]);
$userSource = User::factory()->placeholder()->create();
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
$userDestination = User::factory()->create();
$memberDestination = Member::factory()->forUser($userDestination)->role(Role::Admin)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
'member_id' => $memberDestination->getKey(),
]);
// Assert
$response->assertStatus(422);
$response->assertExactJson([
'errors' => [
'member_id' => [
'The resource does not exist.',
],
],
'message' => 'The resource does not exist.',
]);
}
public function test_merge_into_fails_if_from_member_is_not_a_placeholder(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:merge-into',
]);
$userSource = User::factory()->placeholder()->create();
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Admin)->create();
$userDestination = User::factory()->create();
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
'member_id' => $memberDestination->getKey(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'only_placeholders_can_be_merged_into_another_member',
'message' => 'Only placeholders can be merged into another member',
]);
}
public function test_merge_into_fails_if_user_has_no_permission_to_merge_members(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$userSource = User::factory()->placeholder()->create();
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
$userDestination = User::factory()->create();
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
'member_id' => $memberDestination->getKey(),
]);
// Assert
$response->assertForbidden();
}
public function test_merge_into_assigns_resources_of_source_member_to_destination_member_and_deletes_member(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:merge-into',
]);
$userSource = User::factory()->placeholder()->create();
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
TimeEntry::factory()->forMember($memberSource)->createMany(3);
$project = Project::factory()->forOrganization($data->organization)->create();
ProjectMember::factory()->forMember($memberSource)->forProject($project)->create();
$userDestination = User::factory()->create();
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
'member_id' => $memberDestination->getKey(),
]);
// Assert
$response->assertStatus(204);
$this->assertSame('', $response->getContent());
$this->assertDatabaseMissing(Member::class, [
'id' => $memberSource->getKey(),
]);
$this->assertDatabaseMissing(User::class, [
'id' => $userSource->getKey(),
]);
$memberDestination->refresh();
$this->assertCount(3, $memberDestination->timeEntries);
$this->assertCount(1, $memberDestination->projectMembers);
}
public function test_update_member_fails_if_user_tries_to_change_role_of_the_current_owner(): void
{
// Arrange
@@ -281,6 +439,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
public function test_invite_placeholder_succeeds_if_data_is_valid(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:invite-placeholder',
]);
@@ -301,6 +460,38 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$response->assertStatus(204);
}
public function test_invite_placeholder_fails_if_the_placeholder_has_a_invalid_email_from_an_import(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:invite-placeholder',
]);
$user = User::factory()->create([
'is_placeholder' => true,
'email' => 'some.user@solidtime-import.test',
]);
$member = Member::factory()
->forUser($user)
->forOrganization($data->organization)
->role(Role::Placeholder)
->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.invite-placeholder', [
'organization' => $data->organization->getKey(),
'member' => $member->getKey(),
]));
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception',
'message' => 'This placeholder can not be invited use the merge tool instead',
]);
}
public function test_destroy_member_fails_if_user_has_no_permission_to_delete_members(): void
{
// Arrange
@@ -455,6 +646,34 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
Event::assertNotDispatched(MemberMadeToPlaceholder::class);
}
public function test_make_placeholder_fails_if_user_is_already_a_placeholder(): void
{
// Arrange
Event::fake([
MemberMadeToPlaceholder::class,
]);
$data = $this->createUserWithPermission([
'members:make-placeholder',
]);
$user = User::factory()->placeholder()->create();
$member = Member::factory()->forUser($user)->forOrganization($data->organization)->role(Role::Placeholder)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.make-placeholder', [
'organization' => $data->organization->getKey(),
'member' => $member->getKey(),
]));
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'changing_role_of_placeholder_is_not_allowed',
'message' => 'Changing role of placeholder is not allowed',
]);
}
public function test_make_placeholder_fails_if_member_is_owner(): void
{
// Arrange

View File

@@ -80,7 +80,7 @@ class DashboardEndpointTest extends EndpointTestAbstract
->whereNot('dailyTrackedHours', null)
->whereNot('totalWeeklyTime', null)
->whereNot('totalWeeklyBillableTime', null)
->whereNot('totalWeeklyBillableAmount', null)
->where('totalWeeklyBillableAmount', null)
->whereNot('weeklyHistory', null)
);
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Service\ColorService;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\GenericProjectsImporter;
use App\Service\Import\Importers\ImportException;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
#[CoversClass(GenericProjectsImporter::class)]
#[CoversClass(ImportException::class)]
#[CoversClass(DefaultImporter::class)]
#[UsesClass(GenericProjectsImporter::class)]
class GenericProjectsImporterTest extends ImporterTestAbstract
{
public function test_import_of_test_file_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new GenericProjectsImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('generic_projects_import_test_1.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$clients = Client::all();
$this->assertCount(2, $clients);
$client1 = $clients->firstWhere('name', 'Big Company');
$this->assertNotNull($client1);
$client2 = $clients->firstWhere('name', 'Some client');
$this->assertNotNull($client2);
$projects = Project::all();
$this->assertCount(3, $projects);
// Project 1
$project1 = $projects->firstWhere('name', 'Project for Big Company');
$this->assertNotNull($project1);
$this->assertTrue(app(ColorService::class)->isBuiltInColor($project1->color));
$this->assertSame(10001, $project1->billable_rate);
$this->assertFalse($project1->is_public);
$this->assertSame($client1->getKey(), $project1->client_id);
$this->assertTrue($project1->is_billable);
$this->assertSame(null, $project1->estimated_time);
$this->assertNull($project1->archived_at);
// Project 2
$project2 = $projects->firstWhere('name', 'Project without Client');
$this->assertNotNull($project2);
$this->assertSame('#ef5350', $project2->color);
$this->assertSame(null, $project2->billable_rate);
$this->assertFalse($project2->is_public);
$this->assertSame(null, $project2->client_id);
$this->assertFalse($project2->is_billable);
$this->assertSame(1000, $project2->estimated_time);
$this->assertSame(null, $project2->archived_at);
$project3 = $projects->firstWhere('name', 'Project (Archived)');
$this->assertNotNull($project3);
$this->assertSame('#6a407f', $project3->color);
$this->assertSame(null, $project3->billable_rate);
$this->assertTrue($project3->is_public);
$this->assertSame($client2->getKey(), $project3->client_id);
$this->assertTrue($project3->is_billable);
$this->assertSame(null, $project3->estimated_time);
$this->assertSame('2024-08-25T10:00:00Z', $project3->archived_at->toIso8601ZuluString());
$tasks = Task::all();
$this->assertCount(0, $tasks);
$this->assertSame(0, $report->timeEntriesCreated);
$this->assertSame(0, $report->tagsCreated);
$this->assertSame(0, $report->tasksCreated);
$this->assertSame(0, $report->usersCreated);
$this->assertSame(3, $report->projectsCreated);
$this->assertSame(2, $report->clientsCreated);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Models\Organization;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\GenericTimeEntriesImporter;
use App\Service\Import\Importers\ImportException;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
#[CoversClass(GenericTimeEntriesImporter::class)]
#[CoversClass(ImportException::class)]
#[CoversClass(DefaultImporter::class)]
#[UsesClass(GenericTimeEntriesImporter::class)]
class GenericTimeEntriesImporterTest extends ImporterTestAbstract
{
public function test_import_of_test_file_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new GenericTimeEntriesImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('generic_time_entries_import_test_1.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
$this->checkTimeEntries($testScenario);
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(2, $report->tagsCreated);
$this->assertSame(1, $report->tasksCreated);
$this->assertSame(1, $report->usersCreated);
$this->assertSame(2, $report->projectsCreated);
$this->assertSame(1, $report->clientsCreated);
}
public function test_import_of_test_file_twice_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new GenericTimeEntriesImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('generic_time_entries_import_test_1.csv');
$importer->importData($data, $timezone);
$importer = new GenericTimeEntriesImporter;
$importer->init($organization);
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
$this->checkTimeEntries($testScenario, true);
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(0, $report->tagsCreated);
$this->assertSame(0, $report->tasksCreated);
$this->assertSame(0, $report->usersCreated);
$this->assertSame(0, $report->projectsCreated);
$this->assertSame(0, $report->clientsCreated);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Models\Client;
use App\Models\Organization;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\HarvestClientsImporter;
use App\Service\Import\Importers\ImportException;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
#[CoversClass(HarvestClientsImporter::class)]
#[CoversClass(ImportException::class)]
#[CoversClass(DefaultImporter::class)]
#[UsesClass(HarvestClientsImporter::class)]
class HarvestClientsImporterTest extends ImporterTestAbstract
{
public function test_import_of_test_file_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new HarvestClientsImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('harvest_clients_import_test_1.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$clients = Client::query()->whereBelongsTo($organization, 'organization')->get();
$this->assertCount(2, $clients);
$client1 = $clients->where('name', 'Example Client')->first();
$this->assertNotNull($client1);
// Client name in Harvest: \\ 🔥 Special characters """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\
$client2 = $clients->where('name', '\\\\ 🔥 Special characters """`!@#$%^&*()_+\-=\[\]{};\':"\\\\|,.\'\'<>\/?~ \\\\\\')->first();
$this->assertNotNull($client2);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\HarvestProjectsImporter;
use App\Service\Import\Importers\ImportException;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
#[CoversClass(HarvestProjectsImporter::class)]
#[CoversClass(ImportException::class)]
#[CoversClass(DefaultImporter::class)]
#[UsesClass(HarvestProjectsImporter::class)]
class HarvestProjectsImporterTest extends ImporterTestAbstract
{
public function test_import_of_test_file_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new HarvestProjectsImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('harvest_projects_import_test_1.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$clients = Client::query()->whereBelongsTo($organization, 'organization')->get();
$this->assertCount(2, $clients);
/** @var Client|null $client1 */
$client1 = $clients->where('name', 'Example Client')->first();
$this->assertNotNull($client1);
// Client name in Harvest: \\ 🔥 Special characters client """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\
/** @var Client|null $client2 */
$client2 = $clients->where('name', '\\\\ 🔥 Special characters client """`!@#$%^&*()_+\-=\[\]{};\':"\\\\|,.\'\'<>\/?~ \\\\\\')->first();
$this->assertNotNull($client2);
$projects = Project::query()->whereBelongsTo($organization, 'organization')->get();
$this->assertCount(2, $projects);
/** @var Project|null $project1 */
$project1 = $projects->where('name', 'Example Project')->first();
$this->assertNotNull($project1);
$this->assertSame($client1->getKey(), $project1->client_id);
$this->assertSame(50 * 60 * 60, $project1->estimated_time); // 50h
$this->assertSame(true, $project1->is_billable);
/** @var Project|null $project2 */
$project2 = $projects->where('name', '\\\\ 🔥 Special characters project """`!@#$%^&*()_+\-=\[\]{};\':"\\\\|,.\'\'<>\/?~ \\\\\\')->first();
$this->assertNotNull($project2);
$this->assertSame($client2->getKey(), $project2->client_id);
$this->assertSame(null, $project2->estimated_time);
$this->assertSame(false, $project2->is_billable);
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Enums\Role;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\HarvestTimeEntriesImporter;
use App\Service\Import\Importers\ImportException;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
#[CoversClass(HarvestTimeEntriesImporter::class)]
#[CoversClass(ImportException::class)]
#[CoversClass(DefaultImporter::class)]
#[UsesClass(HarvestTimeEntriesImporter::class)]
class HarvestTimeEntriesImporterTest extends ImporterTestAbstract
{
public function test_import_of_test_file_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new HarvestTimeEntriesImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('harvest_time_entries_import_test_1.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$users = User::all();
$this->assertCount(2, $users);
$user1 = $users->firstWhere('name', 'Peter Tester');
$this->assertNotNull($user1);
$this->assertSame(null, $user1->password);
$this->assertSame('Peter Tester', $user1->name);
$this->assertSame('peter.tester@solidtime-import.test', $user1->email);
$members = Member::all();
$this->assertCount(1, $members);
$member1 = $members->firstWhere('user_id', $user1->getKey());
$this->assertNotNull($member1);
$this->assertSame(Role::Placeholder->value, $member1->role);
$clients = Client::all();
$this->assertCount(1, $clients);
$client1 = $clients->firstWhere('name', 'Big Company');
$this->assertNotNull($client1);
$this->assertNull($client1->archived_at);
$projects = Project::with(['members'])->get();
$this->assertCount(2, $projects);
/** @var Project|null $project1 */
$project1 = $projects->firstWhere('name', 'Project without Client');
$this->assertNotNull($project1);
$this->assertNull($project1->client_id);
/** @var Project|null $project2 */
$project2 = $projects->firstWhere('name', 'Project for Big Company');
$this->assertNotNull($project2);
$this->assertSame($client1->getKey(), $project2->client_id);
$project3 = null;
// Project without Client
$this->assertSame(false, $project1->is_public);
// Project for Big Company
$this->assertSame(false, $project2->is_public);
$tasks = Task::all();
$this->assertCount(1, $tasks);
$task1 = $tasks->firstWhere('name', 'Task 1');
$this->assertNotNull($task1);
$this->assertNull($task1->done_at);
$this->assertSame($project2->getKey(), $task1->project_id);
$tags = Tag::all();
$this->assertCount(0, $tags);
$timeEntries = TimeEntry::all();
$this->assertCount(2, $timeEntries);
$timeEntry1 = $timeEntries->firstWhere('description', '');
$this->assertNotNull($timeEntry1);
$this->assertSame('', $timeEntry1->description);
$this->assertSame('2024-03-03 23:00:00', $timeEntry1->start->toDateTimeString());
$this->assertSame('2024-03-04 19:00:00', $timeEntry1->end->toDateTimeString());
$this->assertFalse($timeEntry1->billable);
$this->assertTrue($timeEntry1->is_imported);
$this->assertSame([], $timeEntry1->tags);
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
$this->assertNotNull($timeEntry2);
$this->assertSame('Working hard', $timeEntry2->description);
$this->assertSame('2024-03-03 23:00:00', $timeEntry2->start->toDateTimeString());
$this->assertSame('2024-03-03 23:00:36', $timeEntry2->end->toDateTimeString());
$this->assertTrue($timeEntry2->billable);
$this->assertTrue($timeEntry2->is_imported);
$this->assertSame([], $timeEntry2->tags);
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(0, $report->tagsCreated);
$this->assertSame(1, $report->tasksCreated);
$this->assertSame(1, $report->usersCreated);
$this->assertSame(2, $report->projectsCreated);
$this->assertSame(1, $report->clientsCreated);
}
}

View File

@@ -42,6 +42,11 @@ class ImporterProviderTest extends TestCase
'clockify_time_entries',
'clockify_projects',
'solidtime',
'harvest_projects',
'harvest_time_entries',
'harvest_clients',
'generic_projects',
'generic_time_entries',
], $keys);
}
}

View File

@@ -41,7 +41,8 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
Weekday::Monday,
false,
null,
null
null,
true
);
// Assert
@@ -88,6 +89,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false,
Carbon::now()->subDays(2)->utc(),
Carbon::now()->subDay()->utc(),
true
);
// Assert
@@ -137,6 +139,91 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
], $result);
}
public function test_aggregate_time_entries_without_billable_amounts(): void
{
// Arrange
$project1 = Project::factory()->create([
// Note: To ensure deterministic order
'id' => '5de4e6df-9560-4675-95be-18d42c441bfc',
]);
$project2 = Project::factory()->create([
// Note: To ensure deterministic order
'id' => '130bdf66-d370-4564-aec7-7171e9b415f7',
]);
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create([
'description' => 'Test',
]);
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create([
'description' => '',
]);
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create([
'description' => 'Test',
]);
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create([
'description' => 'Test',
]);
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
TimeEntryAggregationType::Description,
'Europe/Vienna',
Weekday::Monday,
false,
Carbon::now()->subDays(2)->utc(),
Carbon::now()->subDay()->utc(),
false
);
// Assert
$this->assertSame([
'seconds' => 40,
'cost' => null,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project2->getKey(),
'seconds' => 20,
'cost' => null,
'grouped_type' => 'description',
'grouped_data' => [
[
'key' => null,
'seconds' => 10,
'cost' => null,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => 'Test',
'seconds' => 10,
'cost' => null,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
[
'key' => $project1->getKey(),
'seconds' => 20,
'cost' => null,
'grouped_type' => 'description',
'grouped_data' => [
[
'key' => 'Test',
'seconds' => 20,
'cost' => null,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
],
], $result);
}
public function test_aggregate_time_entries_empty_state_by_day_and_project_with_filled_gaps(): void
{
// Arrange
@@ -153,6 +240,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true,
Carbon::now()->subDays(2)->utc(),
Carbon::now()->subDay()->utc(),
true
);
// Assert
@@ -194,6 +282,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true,
Carbon::now()->subDays(2),
Carbon::now()->subDay(),
true
);
// Assert
@@ -220,6 +309,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true,
Carbon::now()->subDays(2),
Carbon::now()->subDay(),
true
);
// Assert
@@ -254,7 +344,8 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
Weekday::Monday,
false,
null,
null
null,
true
);
// Assert
@@ -342,7 +433,8 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
Weekday::Monday,
true,
null,
null
null,
true
);
// Assert