mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
13 Commits
feature/ta
...
feature/ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85aa182fce | ||
|
|
6c4256b27d | ||
|
|
a6e5d375a1 | ||
|
|
32c7e55a15 | ||
|
|
084647c2a6 | ||
|
|
469f128604 | ||
|
|
c9c221de62 | ||
|
|
878bbd359d | ||
|
|
a6528102fe | ||
|
|
bff766d363 | ||
|
|
2e8da98287 | ||
|
|
a820d8540f | ||
|
|
78ea8a673b |
@@ -82,7 +82,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
}
|
||||
$user = null;
|
||||
$organization = null;
|
||||
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency) {
|
||||
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void {
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
|
||||
@@ -38,7 +38,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
|
||||
AddingTeamMember::dispatch($organization, $newOrganizationMember);
|
||||
|
||||
DB::transaction(function () use ($organization, $newOrganizationMember, $role) {
|
||||
DB::transaction(function () use ($organization, $newOrganizationMember, $role): void {
|
||||
$organization->users()->attach(
|
||||
$newOrganizationMember, ['role' => $role]
|
||||
);
|
||||
@@ -93,7 +93,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
*/
|
||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
||||
{
|
||||
return function ($validator) use ($team, $email) {
|
||||
return function ($validator) use ($team, $email): void {
|
||||
$validator->errors()->addIf(
|
||||
$team->hasRealUserWithEmail($email),
|
||||
'email',
|
||||
|
||||
@@ -54,7 +54,7 @@ class TimeEntrySendStillRunningMailsCommand extends Command
|
||||
$query->where('is_placeholder', '=', false);
|
||||
})
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) {
|
||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
|
||||
/** @var Collection<int, TimeEntry> $timeEntries */
|
||||
foreach ($timeEntries as $timeEntry) {
|
||||
$user = $timeEntry->user;
|
||||
|
||||
11
app/Enums/ProjectMemberRole.php
Normal file
11
app/Enums/ProjectMemberRole.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ProjectMemberRole: string
|
||||
{
|
||||
case Manager = 'manager';
|
||||
case Normal = 'normal';
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->reportable(function (Throwable $e) {
|
||||
$this->reportable(function (Throwable $e): void {
|
||||
//
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ class OrganizationResource extends Resource
|
||||
->persistent()
|
||||
->send();
|
||||
|
||||
return response()->streamDownload(function () use ($file) {
|
||||
return response()->streamDownload(function () use ($file): void {
|
||||
echo Storage::disk(config('filesystems.private'))->get($file);
|
||||
}, 'export.zip');
|
||||
} catch (\Exception $exception) {
|
||||
@@ -137,7 +137,7 @@ class OrganizationResource extends Resource
|
||||
}),
|
||||
Action::make('Import')
|
||||
->icon('heroicon-o-inbox-arrow-down')
|
||||
->action(function (Organization $record, array $data) {
|
||||
->action(function (Organization $record, array $data): void {
|
||||
try {
|
||||
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
|
||||
if ($file === null) {
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillingContract;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
@@ -43,4 +44,9 @@ class Controller extends \App\Http\Controllers\Controller
|
||||
{
|
||||
return $this->permissionStore->has($organization, $permission);
|
||||
}
|
||||
|
||||
protected function canAccessPremiumFeatures(Organization $organization): bool
|
||||
{
|
||||
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\ProjectMemberRole;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Http\Requests\V1\Project\ProjectIndexRequest;
|
||||
use App\Http\Requests\V1\Project\ProjectStoreRequest;
|
||||
@@ -15,6 +16,8 @@ use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Service\BillableRateService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -50,6 +53,12 @@ class ProjectController extends Controller
|
||||
|
||||
if (! $canViewAllProjects) {
|
||||
$projectsQuery->visibleByEmployee($user);
|
||||
$projectsQuery->with([
|
||||
'members' => function (HasMany $query): void {
|
||||
/** @var Builder<ProjectMember> $query */
|
||||
$query->whereBelongsTo($this->user(), 'user');
|
||||
},
|
||||
]);
|
||||
}
|
||||
$filterArchived = $request->getFilterArchived();
|
||||
if ($filterArchived === 'true') {
|
||||
@@ -60,6 +69,14 @@ class ProjectController extends Controller
|
||||
|
||||
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
foreach ($projects->items() as $project) {
|
||||
if ($canViewAllProjects) {
|
||||
$project->setAttribute('limited_visibility', false);
|
||||
} else {
|
||||
$project->setAttribute('limited_visibility', $project->members->firstWhere('user_id', $this->user()->id)?->role !== ProjectMemberRole::Manager);
|
||||
}
|
||||
}
|
||||
|
||||
return new ProjectCollection($projects);
|
||||
}
|
||||
|
||||
@@ -73,6 +90,26 @@ class ProjectController extends Controller
|
||||
public function show(Organization $organization, Project $project): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:view', $project);
|
||||
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
|
||||
|
||||
$project->load([
|
||||
'members' => function (HasMany $query): void {
|
||||
/** @var Builder<ProjectMember> $query */
|
||||
$query->whereBelongsTo($this->user(), 'user');
|
||||
},
|
||||
]);
|
||||
|
||||
if (! $canViewAllProjects) {
|
||||
if (! $project->is_public && $project->members->firstWhere('user_id', '=', $this->user()->id) === null) {
|
||||
throw new AuthorizationException('No access to project');
|
||||
}
|
||||
}
|
||||
|
||||
if ($canViewAllProjects) {
|
||||
$project->setAttribute('limited_visibility', false);
|
||||
} else {
|
||||
$project->setAttribute('limited_visibility', $project->members->firstWhere('user_id', $this->user()->id)?->role !== ProjectMemberRole::Manager);
|
||||
}
|
||||
|
||||
$project->load('organization');
|
||||
|
||||
@@ -95,9 +132,14 @@ class ProjectController extends Controller
|
||||
$project->is_billable = (bool) $request->input('is_billable');
|
||||
$project->billable_rate = $request->getBillableRate();
|
||||
$project->client_id = $request->input('client_id');
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$project->estimated_time = $request->getEstimatedTime();
|
||||
}
|
||||
$project->organization()->associate($organization);
|
||||
$project->save();
|
||||
|
||||
$project->setAttribute('limited_visibility', false);
|
||||
|
||||
return new ProjectResource($project);
|
||||
}
|
||||
|
||||
@@ -117,6 +159,9 @@ class ProjectController extends Controller
|
||||
if ($request->has('is_archived')) {
|
||||
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
|
||||
}
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$project->estimated_time = $request->getEstimatedTime();
|
||||
}
|
||||
$oldBillableRate = $project->billable_rate;
|
||||
$project->billable_rate = $request->getBillableRate();
|
||||
$project->client_id = $request->input('client_id');
|
||||
@@ -126,6 +171,8 @@ class ProjectController extends Controller
|
||||
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
||||
}
|
||||
|
||||
$project->setAttribute('limited_visibility', false);
|
||||
|
||||
return new ProjectResource($project);
|
||||
}
|
||||
|
||||
@@ -147,8 +194,8 @@ class ProjectController extends Controller
|
||||
throw new EntityStillInUseApiException('project', 'time_entry');
|
||||
}
|
||||
|
||||
DB::transaction(function () use (&$project) {
|
||||
$project->members->each(function (ProjectMember $member) {
|
||||
DB::transaction(function () use (&$project): void {
|
||||
$project->members->each(function (ProjectMember $member): void {
|
||||
$member->delete();
|
||||
});
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ class ProjectMemberController extends Controller
|
||||
}
|
||||
|
||||
$projectMember = new ProjectMember;
|
||||
$projectMember->role = $request->getRole();
|
||||
$projectMember->billable_rate = $request->getBillableRate();
|
||||
$projectMember->member()->associate($member);
|
||||
$projectMember->user()->associate($member->user);
|
||||
@@ -95,11 +96,17 @@ class ProjectMemberController extends Controller
|
||||
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
||||
$oldBillableRate = $projectMember->billable_rate;
|
||||
$projectMember->billable_rate = $request->getBillableRate();
|
||||
$hasBillableRate = $request->has('billable_rate');
|
||||
if ($hasBillableRate) {
|
||||
$oldBillableRate = $projectMember->billable_rate;
|
||||
$projectMember->billable_rate = $request->getBillableRate();
|
||||
}
|
||||
if ($request->getRole() !== null) {
|
||||
$projectMember->role = $request->getRole();
|
||||
}
|
||||
$projectMember->save();
|
||||
|
||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||
if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,9 @@ class TaskController extends Controller
|
||||
$task = new Task;
|
||||
$task->name = $request->input('name');
|
||||
$task->project_id = $request->input('project_id');
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$task->estimated_time = $request->getEstimatedTime();
|
||||
}
|
||||
$task->organization()->associate($organization);
|
||||
$task->save();
|
||||
|
||||
@@ -96,6 +99,9 @@ class TaskController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:update', $task);
|
||||
$task->name = $request->input('name');
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$task->estimated_time = $request->getEstimatedTime();
|
||||
}
|
||||
if ($request->has('is_done')) {
|
||||
$task->done_at = $request->getIsDone() ? Carbon::now() : null;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,12 @@ use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
|
||||
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
|
||||
use App\Http\Resources\V1\TimeEntry\TimeEntryResource;
|
||||
use App\Jobs\RecalculateSpentTimeForProject;
|
||||
use App\Jobs\RecalculateSpentTimeForTask;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\TimeEntryAggregationService;
|
||||
use App\Service\TimeEntryFilter;
|
||||
@@ -215,7 +218,16 @@ class TimeEntryController extends Controller
|
||||
throw new TimeEntryStillRunningApiException;
|
||||
}
|
||||
|
||||
$client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null;
|
||||
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
|
||||
$client = $project?->client;
|
||||
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
|
||||
|
||||
if ($project !== null) {
|
||||
RecalculateSpentTimeForProject::dispatch($project);
|
||||
}
|
||||
if ($task !== null) {
|
||||
RecalculateSpentTimeForTask::dispatch($task);
|
||||
}
|
||||
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->fill($request->validated());
|
||||
@@ -250,16 +262,38 @@ class TimeEntryController extends Controller
|
||||
throw new TimeEntryCanNotBeRestartedApiException;
|
||||
}
|
||||
|
||||
$oldProject = $timeEntry->project;
|
||||
$oldTask = $timeEntry->task;
|
||||
|
||||
$project = null;
|
||||
if ($request->has('project_id')) {
|
||||
$client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null;
|
||||
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
|
||||
$client = $project?->client;
|
||||
$timeEntry->client()->associate($client);
|
||||
}
|
||||
$task = null;
|
||||
if ($request->has('task_id')) {
|
||||
$task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;
|
||||
}
|
||||
|
||||
$timeEntry->fill($request->validated());
|
||||
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||
$timeEntry->save();
|
||||
|
||||
if ($oldProject !== null) {
|
||||
RecalculateSpentTimeForProject::dispatch($oldProject);
|
||||
}
|
||||
if ($oldTask !== null) {
|
||||
RecalculateSpentTimeForTask::dispatch($oldTask);
|
||||
}
|
||||
if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {
|
||||
RecalculateSpentTimeForProject::dispatch($project);
|
||||
}
|
||||
if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {
|
||||
RecalculateSpentTimeForTask::dispatch($task);
|
||||
}
|
||||
|
||||
return new TimeEntryResource($timeEntry);
|
||||
}
|
||||
|
||||
@@ -279,6 +313,10 @@ class TimeEntryController extends Controller
|
||||
|
||||
$timeEntries = TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->with([
|
||||
'project',
|
||||
'task',
|
||||
])
|
||||
->whereIn('id', $ids)
|
||||
->get();
|
||||
|
||||
@@ -288,13 +326,20 @@ class TimeEntryController extends Controller
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$project = null;
|
||||
$client = null;
|
||||
$overwriteClient = false;
|
||||
if ($request->has('changes.project_id')) {
|
||||
$client = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id'))->client : null;
|
||||
$project = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id')) : null;
|
||||
$client = $project?->client;
|
||||
$overwriteClient = true;
|
||||
}
|
||||
|
||||
$task = null;
|
||||
if ($request->has('changes.task_id')) {
|
||||
$task = $request->input('changes.task_id') !== null ? Task::findOrFail((string) $request->input('changes.task_id')) : null;
|
||||
}
|
||||
|
||||
$success = new Collection;
|
||||
$error = new Collection;
|
||||
|
||||
@@ -313,12 +358,28 @@ class TimeEntryController extends Controller
|
||||
continue;
|
||||
|
||||
}
|
||||
$oldProject = $timeEntry->project;
|
||||
$oldTask = $timeEntry->task;
|
||||
|
||||
$timeEntry->fill($changes);
|
||||
if ($overwriteClient) {
|
||||
$timeEntry->client()->associate($client);
|
||||
}
|
||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||
$timeEntry->save();
|
||||
if ($oldTask !== null) {
|
||||
RecalculateSpentTimeForTask::dispatch($oldTask);
|
||||
}
|
||||
if ($oldProject !== null) {
|
||||
RecalculateSpentTimeForProject::dispatch($oldProject);
|
||||
}
|
||||
if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {
|
||||
RecalculateSpentTimeForProject::dispatch($project);
|
||||
}
|
||||
if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {
|
||||
RecalculateSpentTimeForTask::dispatch($task);
|
||||
}
|
||||
|
||||
$success->push($id);
|
||||
}
|
||||
|
||||
@@ -343,8 +404,18 @@ class TimeEntryController extends Controller
|
||||
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
||||
}
|
||||
|
||||
$project = $timeEntry->project;
|
||||
$task = $timeEntry->task;
|
||||
|
||||
$timeEntry->delete();
|
||||
|
||||
if ($project !== null) {
|
||||
RecalculateSpentTimeForProject::dispatch($project);
|
||||
}
|
||||
if ($task !== null) {
|
||||
RecalculateSpentTimeForTask::dispatch($task);
|
||||
}
|
||||
|
||||
return response()
|
||||
->json(null, 204);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ class ProjectStoreRequest extends FormRequest
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
// ID of the client
|
||||
'client_id' => [
|
||||
'nullable',
|
||||
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
|
||||
@@ -59,6 +60,12 @@ class ProjectStoreRequest extends FormRequest
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
],
|
||||
// Estimated time in seconds
|
||||
'estimated_time' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -68,4 +75,11 @@ class ProjectStoreRequest extends FormRequest
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
||||
}
|
||||
|
||||
public function getEstimatedTime(): ?int
|
||||
{
|
||||
$input = $this->input('estimated_time');
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ class ProjectUpdateRequest extends FormRequest
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
// Estimated time in seconds
|
||||
'estimated_time' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -78,4 +84,11 @@ class ProjectUpdateRequest extends FormRequest
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
||||
}
|
||||
|
||||
public function getEstimatedTime(): ?int
|
||||
{
|
||||
$input = $this->input('estimated_time');
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\ProjectMember;
|
||||
|
||||
use App\Enums\ProjectMemberRole;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
@@ -19,7 +21,7 @@ class ProjectMemberStoreRequest extends FormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -37,6 +39,11 @@ class ProjectMemberStoreRequest extends FormRequest
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::enum(ProjectMemberRole::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -46,4 +53,9 @@ class ProjectMemberStoreRequest extends FormRequest
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
||||
}
|
||||
|
||||
public function getRole(): ProjectMemberRole
|
||||
{
|
||||
return ProjectMemberRole::from($this->validated('role'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\ProjectMember;
|
||||
|
||||
use App\Enums\ProjectMemberRole;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
@@ -16,7 +18,7 @@ class ProjectMemberUpdateRequest extends FormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -26,6 +28,10 @@ class ProjectMemberUpdateRequest extends FormRequest
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
'role' => [
|
||||
'string',
|
||||
Rule::enum(ProjectMemberRole::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -33,6 +39,11 @@ class ProjectMemberUpdateRequest extends FormRequest
|
||||
{
|
||||
$input = $this->input('billable_rate');
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
||||
return $input !== null && ((int) $input) !== 0 ? (int) $this->validated('billable_rate') : null;
|
||||
}
|
||||
|
||||
public function getRole(): ?ProjectMemberRole
|
||||
{
|
||||
return $this->has('role') ? ProjectMemberRole::from($this->validated('role')) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,19 @@ class TaskStoreRequest extends FormRequest
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
],
|
||||
// Estimated time in seconds
|
||||
'estimated_time' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getEstimatedTime(): ?int
|
||||
{
|
||||
$input = $this->input('estimated_time');
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ class TaskUpdateRequest extends FormRequest
|
||||
'is_done' => [
|
||||
'boolean',
|
||||
],
|
||||
// Estimated time in seconds
|
||||
'estimated_time' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -47,4 +53,11 @@ class TaskUpdateRequest extends FormRequest
|
||||
|
||||
return $this->boolean('is_done');
|
||||
}
|
||||
|
||||
public function getEstimatedTime(): ?int
|
||||
{
|
||||
$input = $this->input('estimated_time');
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ class ProjectResource extends BaseResource
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$limitedVisibility = is_bool($this->resource->getAttributeValue('limited_visibility')) ? $this->resource->getAttributeValue('limited_visibility') : true;
|
||||
|
||||
return [
|
||||
/** @var string $id ID of project */
|
||||
'id' => $this->resource->id,
|
||||
@@ -32,9 +34,15 @@ class ProjectResource extends BaseResource
|
||||
/** @var bool $is_archived Whether the client is archived */
|
||||
'is_archived' => $this->resource->is_archived,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
'billable_rate' => $limitedVisibility ? null : $this->resource->billable_rate,
|
||||
/** @var bool $is_billable Project time entries billable default */
|
||||
'is_billable' => $this->resource->is_billable,
|
||||
/** @var int|null $estimated_time Estimated time in seconds */
|
||||
'estimated_time' => $limitedVisibility ? null : $this->resource->estimated_time,
|
||||
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||
'spent_time' => $limitedVisibility ? null : $this->resource->spent_time,
|
||||
/** @var bool $limited_visibility */
|
||||
'limited_visibility' => $limitedVisibility,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ class ProjectMemberResource extends BaseResource
|
||||
'member_id' => $this->resource->member_id,
|
||||
/** @var string $project_id ID of the project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
/** @var string $role Role of the project member */
|
||||
'role' => $this->resource->role->value,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ class TaskResource extends BaseResource
|
||||
'is_done' => $this->resource->is_done,
|
||||
/** @var string $project_id ID of the project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
/** @var int|null $estimated_time Estimated time in seconds */
|
||||
'estimated_time' => $this->resource->estimated_time,
|
||||
/** @var int $spent_time Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||
'spent_time' => $this->resource->spent_time,
|
||||
/** @var string $created_at When the tag was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string $updated_at When the tag was last updated */
|
||||
|
||||
44
app/Jobs/RecalculateSpentTimeForProject.php
Normal file
44
app/Jobs/RecalculateSpentTimeForProject.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Project;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RecalculateSpentTimeForProject implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public Project $project;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(Project $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$this->project->setComputedAttributeValue('spent_time');
|
||||
if ($this->project->isDirty()) {
|
||||
$this->project->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
44
app/Jobs/RecalculateSpentTimeForTask.php
Normal file
44
app/Jobs/RecalculateSpentTimeForTask.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Task;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RecalculateSpentTimeForTask implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public Task $task;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(Task $task)
|
||||
{
|
||||
$this->task = $task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$this->task->setComputedAttributeValue('spent_time');
|
||||
if ($this->task->isDirty()) {
|
||||
$this->task->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
@@ -27,6 +29,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property bool $is_public
|
||||
* @property bool $is_billable
|
||||
* @property-read bool $is_archived
|
||||
* @property int|null $estimated_time
|
||||
* @property int $spent_time
|
||||
* @property Carbon|null $archived_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
@@ -40,6 +44,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
*/
|
||||
class Project extends Model implements AuditableContract
|
||||
{
|
||||
use ComputedAttributes;
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<ProjectFactory> */
|
||||
@@ -56,6 +61,8 @@ class Project extends Model implements AuditableContract
|
||||
'name' => 'string',
|
||||
'color' => 'string',
|
||||
'archived_at' => 'datetime',
|
||||
'estimated_time' => 'integer',
|
||||
'spent_time' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -67,6 +74,68 @@ class Project extends Model implements AuditableContract
|
||||
'is_billable' => false,
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that are computed. (f.e. for performance reasons)
|
||||
* These attributes can be regenerated at any time.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $computed = [
|
||||
'spent_time',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attributes to exclude from the Audit.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $auditExclude = [
|
||||
'spent_time',
|
||||
];
|
||||
|
||||
public function getSpentTimeComputed(): ?int
|
||||
{
|
||||
if ($this->hasAttribute('spent_time_computed')) {
|
||||
return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed'];
|
||||
} else {
|
||||
/** @var object{ spent_time: string } $result */
|
||||
$result = $this->timeEntries()
|
||||
->whereNotNull('end')
|
||||
->selectRaw('sum(extract(epoch from ("end" - start))) as spent_time')
|
||||
->first();
|
||||
|
||||
return (int) $result->spent_time;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
|
||||
*
|
||||
* @param Builder<Project> $builder
|
||||
* @param array<string> $attributes Attributes that will be generated.
|
||||
* @return Builder<Project>
|
||||
*/
|
||||
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
|
||||
{
|
||||
if (in_array('spent_time', $attributes, true)) {
|
||||
$builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from ("end" - start))'), 'sum');
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
|
||||
*
|
||||
* @param Builder<Project> $builder
|
||||
* @param array<string> $attributes Attributes that will be validated.
|
||||
* @return Builder<Project>
|
||||
*/
|
||||
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
|
||||
{
|
||||
return $this->scopeComputedAttributesGenerate($builder, $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, Project>
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ProjectMemberRole;
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\ProjectMemberFactory;
|
||||
@@ -22,6 +23,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $user_id User ID (legacy)
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property ProjectMemberRole $role
|
||||
* @property-read Project $project
|
||||
* @property-read Member $member
|
||||
* @property-read User $user
|
||||
@@ -45,6 +47,7 @@ class ProjectMember extends Model implements AuditableContract
|
||||
*/
|
||||
protected $casts = [
|
||||
'billable_rate' => 'int',
|
||||
'role' => ProjectMemberRole::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
@@ -23,6 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $project_id
|
||||
* @property string $organization_id
|
||||
* @property Carbon|null $done_at
|
||||
* @property int|null $estimated_time
|
||||
* @property int $spent_time
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Project $project
|
||||
@@ -34,6 +38,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
*/
|
||||
class Task extends Model implements AuditableContract
|
||||
{
|
||||
use ComputedAttributes;
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<TaskFactory> */
|
||||
@@ -48,9 +53,72 @@ class Task extends Model implements AuditableContract
|
||||
*/
|
||||
protected $casts = [
|
||||
'name' => 'string',
|
||||
'estimated_time' => 'integer',
|
||||
'done_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that are computed. (f.e. for performance reasons)
|
||||
* These attributes can be regenerated at any time.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $computed = [
|
||||
'spent_time',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attributes to exclude from the Audit.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $auditExclude = [
|
||||
'spent_time',
|
||||
];
|
||||
|
||||
public function getSpentTimeComputed(): ?int
|
||||
{
|
||||
if ($this->hasAttribute('spent_time_computed')) {
|
||||
return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed'];
|
||||
} else {
|
||||
/** @var object{ spent_time: string } $result */
|
||||
$result = $this->timeEntries()
|
||||
->whereNotNull('end')
|
||||
->selectRaw('sum(extract(epoch from ("end" - start))) as spent_time')
|
||||
->first();
|
||||
|
||||
return (int) $result->spent_time;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
|
||||
*
|
||||
* @param Builder<Task> $builder
|
||||
* @param array<string> $attributes Attributes that will be generated.
|
||||
* @return Builder<Task>
|
||||
*/
|
||||
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
|
||||
{
|
||||
if (in_array('spent_time', $attributes, true)) {
|
||||
$builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from ("end" - start))'), 'sum');
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
|
||||
*
|
||||
* @param Builder<Task> $builder
|
||||
* @param array<string> $attributes Attributes that will be validated.
|
||||
* @return Builder<Task>
|
||||
*/
|
||||
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
|
||||
{
|
||||
return $this->scopeComputedAttributesGenerate($builder, $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, Task>
|
||||
*/
|
||||
|
||||
@@ -81,6 +81,15 @@ class TimeEntry extends Model implements AuditableContract
|
||||
'billable_rate',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attributes to exclude from the Audit.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $auditExclude = [
|
||||
'billable_rate',
|
||||
];
|
||||
|
||||
public function getBillableRateComputed(): ?int
|
||||
{
|
||||
return app(BillableRateService::class)->getBillableRateForTimeEntry($this);
|
||||
|
||||
@@ -81,10 +81,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
});
|
||||
|
||||
// Scramble
|
||||
Scramble::extendOpenApi(function (OpenApi $openApi) {
|
||||
Scramble::extendOpenApi(function (OpenApi $openApi): void {
|
||||
$openApi->secure(
|
||||
SecurityScheme::oauth2()
|
||||
->flow('authorizationCode', function (OAuthFlow $flow) {
|
||||
->flow('authorizationCode', function (OAuthFlow $flow): void {
|
||||
$flow
|
||||
->authorizationUrl('https://solidtime.test/oauth/authorize');
|
||||
})
|
||||
|
||||
@@ -37,9 +37,9 @@ class RouteServiceProvider extends ServiceProvider
|
||||
: Limit::perMinute(60)->by($request->ip());
|
||||
});
|
||||
|
||||
$this->routes(function () {
|
||||
$this->routes(function (): void {
|
||||
Route::middleware('health-check')
|
||||
->group(function () {
|
||||
->group(function (): void {
|
||||
Route::get('health-check/up', [HealthCheckController::class, 'up']);
|
||||
Route::get('health-check/debug', [HealthCheckController::class, 'debug']);
|
||||
});
|
||||
|
||||
@@ -28,9 +28,9 @@ class BillableRateService
|
||||
->where('billable', '=', true)
|
||||
->where('organization_id', '=', $project->organization_id)
|
||||
->whereBelongsTo($project, 'project')
|
||||
->whereDoesntHave('member', function (Builder $query) use ($project) {
|
||||
->whereDoesntHave('member', function (Builder $query) use ($project): void {
|
||||
/** @var Builder<Member> $query */
|
||||
$query->whereHas('projectMembers', function (Builder $query) use ($project) {
|
||||
$query->whereHas('projectMembers', function (Builder $query) use ($project): void {
|
||||
/** @var Builder<ProjectMember> $query */
|
||||
$query->whereBelongsTo($project, 'project')
|
||||
->whereNotNull('billable_rate');
|
||||
@@ -62,7 +62,7 @@ class BillableRateService
|
||||
TimeEntry::query()
|
||||
->where('billable', '=', true)
|
||||
->where('organization_id', '=', $organization->getKey())
|
||||
->whereDoesntHave('member', function (Builder $builder) {
|
||||
->whereDoesntHave('member', function (Builder $builder): void {
|
||||
/** @var Builder<Member> $builder */
|
||||
$builder->whereNotNull('billable_rate');
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@ class DeletionService
|
||||
public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void
|
||||
{
|
||||
if ($inTransaction) {
|
||||
DB::transaction(function () use ($organization) {
|
||||
DB::transaction(function () use ($organization): void {
|
||||
$this->deleteOrganization($organization, false);
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ class DeletionService
|
||||
public function deleteUser(User $user, bool $inTransaction = true): void
|
||||
{
|
||||
if ($inTransaction) {
|
||||
DB::transaction(function () use ($user) {
|
||||
DB::transaction(function () use ($user): void {
|
||||
$this->deleteUser($user, false);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class ImportService
|
||||
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
|
||||
|
||||
if ($lock->get()) {
|
||||
DB::transaction(function () use (&$importer, &$data, &$timezone) {
|
||||
DB::transaction(function () use (&$importer, &$data, &$timezone): void {
|
||||
$importer->importData($data, $timezone);
|
||||
});
|
||||
$lock->release();
|
||||
|
||||
@@ -113,7 +113,7 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'nullable',
|
||||
'integer',
|
||||
],
|
||||
], beforeSave: function (Project $project) {
|
||||
], beforeSave: function (Project $project): void {
|
||||
if ($project->billable_rate === 0) {
|
||||
$project->billable_rate = null;
|
||||
}
|
||||
@@ -126,7 +126,7 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'nullable',
|
||||
'integer',
|
||||
],
|
||||
], beforeSave: function (ProjectMember $projectMember) {
|
||||
], beforeSave: function (ProjectMember $projectMember): void {
|
||||
if ($projectMember->billable_rate === 0) {
|
||||
$projectMember->billable_rate = null;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class MemberFactory extends Factory
|
||||
|
||||
public function attachToOrganization(Organization $organization, array $pivot = []): static
|
||||
{
|
||||
return $this->afterCreating(function (User $user) use ($organization, $pivot) {
|
||||
return $this->afterCreating(function (User $user) use ($organization, $pivot): void {
|
||||
$user->organizations()->attach($organization, $pivot);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,9 +33,19 @@ class ProjectFactory extends Factory
|
||||
'archived_at' => null,
|
||||
'client_id' => null,
|
||||
'organization_id' => Organization::factory(),
|
||||
'estimated_time' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function withEstimatedTime(): self
|
||||
{
|
||||
return $this->state(function (array $attributes): array {
|
||||
return [
|
||||
'estimated_time' => $this->faker->randomNumber(3),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function billable(): self
|
||||
{
|
||||
return $this->state(function (array $attributes): array {
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\ProjectMemberRole;
|
||||
use App\Models\Member;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
@@ -24,12 +25,22 @@ class ProjectMemberFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'billable_rate' => $this->faker->numberBetween(10, 10000) * 100,
|
||||
'role' => ProjectMemberRole::Normal,
|
||||
'project_id' => Project::factory(),
|
||||
'user_id' => User::factory(),
|
||||
'member_id' => Member::factory(),
|
||||
];
|
||||
}
|
||||
|
||||
public function role(ProjectMemberRole $role): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($role) {
|
||||
return [
|
||||
'role' => $role,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use forMember instead
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,7 @@ class TaskFactory extends Factory
|
||||
'project_id' => Project::factory(),
|
||||
'organization_id' => Organization::factory(),
|
||||
'done_at' => null,
|
||||
'estimated_time' => null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -147,8 +147,8 @@ class TimeEntryFactory extends Factory
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($start, $durationInSeconds): array {
|
||||
return [
|
||||
'start' => $start->utc(),
|
||||
'end' => $start->copy()->addSeconds($durationInSeconds),
|
||||
'start' => $start->copy()->utc(),
|
||||
'end' => $start->copy()->utc()->addSeconds($durationInSeconds),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -157,7 +157,7 @@ class TimeEntryFactory extends Factory
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($start): array {
|
||||
return [
|
||||
'start' => $start->utc(),
|
||||
'start' => $start->copy()->utc(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ class UserFactory extends Factory
|
||||
|
||||
public function attachToOrganization(Organization $organization, array $pivot = []): static
|
||||
{
|
||||
return $this->afterCreating(function (User $user) use ($organization, $pivot) {
|
||||
return $this->afterCreating(function (User $user) use ($organization, $pivot): void {
|
||||
$user->organizations()->attach($organization, $pivot);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name');
|
||||
$table->string('email');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table): void {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
|
||||
@@ -14,7 +14,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->text('two_factor_secret')
|
||||
->after('password')
|
||||
->nullable();
|
||||
@@ -36,7 +36,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->dropColumn(array_merge([
|
||||
'two_factor_secret',
|
||||
'two_factor_recovery_codes',
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_auth_codes', function (Blueprint $table) {
|
||||
Schema::create('oauth_auth_codes', function (Blueprint $table): void {
|
||||
$table->string('id', 100)->primary();
|
||||
$table->foreignUuid('user_id')->index();
|
||||
$table->uuid('client_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_access_tokens', function (Blueprint $table) {
|
||||
Schema::create('oauth_access_tokens', function (Blueprint $table): void {
|
||||
$table->string('id', 100)->primary();
|
||||
$table->foreignUuid('user_id')->nullable()->index();
|
||||
$table->uuid('client_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_refresh_tokens', function (Blueprint $table) {
|
||||
Schema::create('oauth_refresh_tokens', function (Blueprint $table): void {
|
||||
$table->string('id', 100)->primary();
|
||||
$table->string('access_token_id', 100)->index();
|
||||
$table->boolean('revoked');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_clients', function (Blueprint $table) {
|
||||
Schema::create('oauth_clients', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->nullable()->index();
|
||||
$table->string('name');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_personal_access_clients', function (Blueprint $table) {
|
||||
Schema::create('oauth_personal_access_clients', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->uuid('client_id');
|
||||
$table->timestamps();
|
||||
|
||||
@@ -26,7 +26,7 @@ return new class extends Migration
|
||||
}
|
||||
$schema = Schema::connection($this->getConnection());
|
||||
|
||||
$schema->create('telescope_entries', function (Blueprint $table) {
|
||||
$schema->create('telescope_entries', function (Blueprint $table): void {
|
||||
$table->bigIncrements('sequence');
|
||||
$table->uuid('uuid');
|
||||
$table->uuid('batch_id');
|
||||
@@ -43,7 +43,7 @@ return new class extends Migration
|
||||
$table->index(['type', 'should_display_on_index']);
|
||||
});
|
||||
|
||||
$schema->create('telescope_entries_tags', function (Blueprint $table) {
|
||||
$schema->create('telescope_entries_tags', function (Blueprint $table): void {
|
||||
$table->uuid('entry_uuid');
|
||||
$table->string('tag');
|
||||
|
||||
@@ -56,7 +56,7 @@ return new class extends Migration
|
||||
->onDelete('cascade');
|
||||
});
|
||||
|
||||
$schema->create('telescope_monitoring', function (Blueprint $table) {
|
||||
$schema->create('telescope_monitoring', function (Blueprint $table): void {
|
||||
$table->string('tag')->primary();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
Schema::create('failed_jobs', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->text('connection');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||
Schema::create('personal_access_tokens', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->morphs('tokenable');
|
||||
$table->string('name');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('organizations', function (Blueprint $table) {
|
||||
Schema::create('organizations', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->index();
|
||||
$table->string('name');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('organization_user', function (Blueprint $table) {
|
||||
Schema::create('organization_user', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('organization_id');
|
||||
$table->foreignUuid('user_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('organization_invitations', function (Blueprint $table) {
|
||||
Schema::create('organization_invitations', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('organization_id')
|
||||
->constrained()
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
Schema::create('sessions', function (Blueprint $table): void {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignUuid('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('clients', function (Blueprint $table) {
|
||||
Schema::create('clients', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name', 255);
|
||||
$table->uuid('organization_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('projects', function (Blueprint $table) {
|
||||
Schema::create('projects', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name', 255);
|
||||
$table->string('color', 16);
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tasks', function (Blueprint $table) {
|
||||
Schema::create('tasks', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name', 500);
|
||||
$table->uuid('project_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tags', function (Blueprint $table) {
|
||||
Schema::create('tags', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name', 255);
|
||||
$table->uuid('organization_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('time_entries', function (Blueprint $table) {
|
||||
Schema::create('time_entries', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('description', 500);
|
||||
$table->dateTime('start');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_members', function (Blueprint $table) {
|
||||
Schema::create('project_members', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->integer('billable_rate')->unsigned()->nullable();
|
||||
$table->uuid('project_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
Schema::create('jobs', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
|
||||
@@ -13,13 +13,13 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
Schema::create('cache', function (Blueprint $table): void {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->integer('expiration');
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
Schema::create('cache_locks', function (Blueprint $table): void {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->integer('expiration');
|
||||
|
||||
@@ -14,7 +14,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table) {
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->foreignUuid('client_id')
|
||||
->nullable()
|
||||
->constrained('clients')
|
||||
@@ -35,7 +35,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table) {
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->dropForeign(['client_id']);
|
||||
$table->dropColumn('client_id');
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->boolean('is_billable')->default(false);
|
||||
});
|
||||
DB::statement('
|
||||
@@ -22,7 +22,7 @@ return new class extends Migration
|
||||
set is_billable = true
|
||||
where projects.billable_rate is not null and projects.billable_rate > 0
|
||||
');
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->boolean('is_billable')->default(null)->change();
|
||||
});
|
||||
}
|
||||
@@ -32,7 +32,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->dropColumn('is_billable');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table) {
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->boolean('is_imported')->default(false);
|
||||
});
|
||||
}
|
||||
@@ -23,7 +23,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table) {
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->dropColumn('is_imported');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table) {
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->dropForeign(['member_id']);
|
||||
$table->foreign('member_id')
|
||||
->references('id')
|
||||
@@ -27,7 +27,7 @@ return new class extends Migration
|
||||
->restrictOnDelete()
|
||||
->cascadeOnUpdate();
|
||||
});
|
||||
Schema::table('project_members', function (Blueprint $table) {
|
||||
Schema::table('project_members', function (Blueprint $table): void {
|
||||
$table->dropForeign(['member_id']);
|
||||
$table->foreign('member_id')
|
||||
->references('id')
|
||||
@@ -35,7 +35,7 @@ return new class extends Migration
|
||||
->restrictOnDelete()
|
||||
->cascadeOnUpdate();
|
||||
});
|
||||
Schema::table('organization_invitations', function (Blueprint $table) {
|
||||
Schema::table('organization_invitations', function (Blueprint $table): void {
|
||||
$table->dropForeign(['organization_id']);
|
||||
$table->foreign('organization_id')
|
||||
->references('id')
|
||||
@@ -50,7 +50,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table) {
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->dropForeign(['member_id']);
|
||||
$table->foreign('member_id')
|
||||
->references('id')
|
||||
@@ -64,7 +64,7 @@ return new class extends Migration
|
||||
->cascadeOnDelete()
|
||||
->cascadeOnUpdate();
|
||||
});
|
||||
Schema::table('project_members', function (Blueprint $table) {
|
||||
Schema::table('project_members', function (Blueprint $table): void {
|
||||
$table->dropForeign(['member_id']);
|
||||
$table->foreign('member_id')
|
||||
->references('id')
|
||||
@@ -72,7 +72,7 @@ return new class extends Migration
|
||||
->cascadeOnDelete()
|
||||
->cascadeOnUpdate();
|
||||
});
|
||||
Schema::table('organization_invitations', function (Blueprint $table) {
|
||||
Schema::table('organization_invitations', function (Blueprint $table): void {
|
||||
$table->dropForeign(['organization_id']);
|
||||
$table->foreign('organization_id')
|
||||
->references('id')
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->integer('estimated_time')->unsigned()->nullable();
|
||||
});
|
||||
Schema::table('tasks', function (Blueprint $table): void {
|
||||
$table->integer('estimated_time')->unsigned()->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->dropColumn('estimated_time');
|
||||
});
|
||||
Schema::table('tasks', function (Blueprint $table): void {
|
||||
$table->dropColumn('estimated_time');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table) {
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->dateTime('still_active_email_sent_at')->nullable();
|
||||
});
|
||||
}
|
||||
@@ -23,7 +23,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table) {
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->dropColumn('still_active_email_sent_at');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ class CreateAuditsTable extends Migration
|
||||
$connection = config('audit.drivers.database.connection', config('database.default'));
|
||||
$table = config('audit.drivers.database.table', 'audits');
|
||||
|
||||
Schema::connection($connection)->create($table, function (Blueprint $table) {
|
||||
Schema::connection($connection)->create($table, function (Blueprint $table): void {
|
||||
|
||||
$morphPrefix = config('audit.user.morph_prefix', 'user');
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->integer('spent_time')->unsigned()->default(0);
|
||||
});
|
||||
Schema::table('tasks', function (Blueprint $table): void {
|
||||
$table->integer('spent_time')->unsigned()->default(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->dropColumn('spent_time');
|
||||
});
|
||||
Schema::table('tasks', function (Blueprint $table): void {
|
||||
$table->dropColumn('spent_time');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('project_members', function (Blueprint $table): void {
|
||||
$table->string('role')->default('normal');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('project_members', function (Blueprint $table): void {
|
||||
$table->dropColumn('role');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -33,7 +33,10 @@ test('test that updating project member billable rate works for existing time en
|
||||
.first()
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Edit' }).first().click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Edit Project Member' })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByLabel('Billable Rate').fill(newBillableRate.toString());
|
||||
await page.getByRole('button', { name: 'Update Project Member' }).click();
|
||||
|
||||
|
||||
33
package-lock.json
generated
33
package-lock.json
generated
@@ -10,7 +10,6 @@
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@rushstack/eslint-patch": "^1.7.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/vue-table": "^8.20.5",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
@@ -1664,19 +1663,6 @@
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
|
||||
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.9.0.tgz",
|
||||
@@ -1687,25 +1673,6 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-table": {
|
||||
"version": "8.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.20.5.tgz",
|
||||
"integrity": "sha512-2xixT3BEgSDw+jOSqPt6ylO/eutDI107t2WdFMVYIZZ45UmTHLySqNriNs0+dMaKR56K5z3t+97P6VuVnI2L+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.20.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-virtual": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.9.0.tgz",
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@rushstack/eslint-patch": "^1.7.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/vue-table": "^8.20.5",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"declare_strict_types": true,
|
||||
"strict_comparison": true,
|
||||
"strict_param": true,
|
||||
"no_unused_imports": true
|
||||
"no_unused_imports": true,
|
||||
"void_return": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,28 +7,23 @@ import {
|
||||
import type { Client } from '@/packages/api/src';
|
||||
import { canDeleteClients, canUpdateClients } from '@/utils/permissions';
|
||||
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
|
||||
import ClientEditModal from '@/Components/Common/Client/ClientEditModal.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [];
|
||||
edit: [];
|
||||
archive: [];
|
||||
}>();
|
||||
const props = defineProps<{
|
||||
client: Client;
|
||||
}>();
|
||||
const showEditModal = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientEditModal
|
||||
:client="client"
|
||||
v-model:show="showEditModal"></ClientEditModal>
|
||||
<MoreOptionsDropdown :label="'Actions for Client ' + props.client.name">
|
||||
<div class="min-w-[150px]">
|
||||
<button
|
||||
v-if="canUpdateClients()"
|
||||
@click="showEditModal = true"
|
||||
@click="emit('edit')"
|
||||
:aria-label="'Edit Client ' + props.client.name"
|
||||
data-testid="client_edit"
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
|
||||
@@ -1,157 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import { UserCircleIcon } from '@heroicons/vue/24/solid';
|
||||
import {
|
||||
ChevronUpDownIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PlusIcon,
|
||||
} from '@heroicons/vue/16/solid';
|
||||
import { type Component, computed, h, ref, watchEffect } from 'vue';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import { type Component, ref } from 'vue';
|
||||
import { type Client } from '@/packages/api/src';
|
||||
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
|
||||
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
||||
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
|
||||
import { canCreateClients } from '@/utils/permissions';
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
clients: Client[];
|
||||
}>();
|
||||
const createClient = ref(false);
|
||||
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
useVueTable,
|
||||
createColumnHelper,
|
||||
type SortingState,
|
||||
getSortedRowModel,
|
||||
} from '@tanstack/vue-table';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import ClientEditModal from '@/Components/Common/Client/ClientEditModal.vue';
|
||||
import TableRow from '@/Components/TableRow.vue';
|
||||
import TableCell from '@/Components/TableCell.vue';
|
||||
|
||||
const columnHelper = createColumnHelper<Client>();
|
||||
const { projects } = storeToRefs(useProjectsStore());
|
||||
|
||||
const columns = computed(() => [
|
||||
columnHelper.accessor((row) => row.name, {
|
||||
id: 'name',
|
||||
cell: (info) => info.getValue(),
|
||||
header: () => 'Name',
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: 'projects',
|
||||
sortingFn: (a, b) => {
|
||||
return (
|
||||
projects.value.filter(
|
||||
(projects) => projects.client_id === a.original.id
|
||||
).length -
|
||||
projects.value.filter(
|
||||
(projects) => projects.client_id === b.original.id
|
||||
).length
|
||||
);
|
||||
},
|
||||
cell: (info) =>
|
||||
h('div', {
|
||||
innerHTML:
|
||||
projects.value.filter(
|
||||
(projects) => projects.client_id === info.getValue().id
|
||||
).length + ' Projects',
|
||||
}),
|
||||
header: () => 'Projects',
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: 'status',
|
||||
enableSorting: false,
|
||||
cell: (info) =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'flex space-x-1 items-center',
|
||||
},
|
||||
[
|
||||
h(CheckCircleIcon, {
|
||||
class: 'w-5',
|
||||
}),
|
||||
h('span', {
|
||||
innerHTML: info.getValue().is_archived
|
||||
? 'Archived'
|
||||
: 'Active',
|
||||
}),
|
||||
]
|
||||
),
|
||||
header: () => 'Status',
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'actions',
|
||||
cell: (info) => {
|
||||
const showEditModal = ref(false);
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: 'flex space-x-1 items-center',
|
||||
},
|
||||
[
|
||||
h(ClientEditModal, {
|
||||
client: info.row.original,
|
||||
show: showEditModal.value,
|
||||
}),
|
||||
h(ClientMoreOptionsDropdown, {
|
||||
class: 'w-5',
|
||||
client: info.row.original,
|
||||
onEdit: () => (showEditModal.value = true),
|
||||
onArchive: () => {
|
||||
useClientsStore().updateClient(
|
||||
info.row.original.id,
|
||||
{
|
||||
...info.row.original,
|
||||
is_archived: !info.row.original.is_archived,
|
||||
}
|
||||
);
|
||||
},
|
||||
onDelete: () => {
|
||||
useClientsStore().deleteClient(
|
||||
info.row.original.id
|
||||
);
|
||||
},
|
||||
}),
|
||||
]
|
||||
);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const data = ref(props.clients);
|
||||
|
||||
watchEffect(() => {
|
||||
data.value = props.clients;
|
||||
});
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return data.value;
|
||||
},
|
||||
onSortingChange: (updaterOrValue) => {
|
||||
sorting.value =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(sorting.value)
|
||||
: updaterOrValue;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value;
|
||||
},
|
||||
},
|
||||
columns: columns.value,
|
||||
});
|
||||
const sorting = ref<SortingState>([]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -162,43 +23,7 @@ const sorting = ref<SortingState>([]);
|
||||
data-testid="client_table"
|
||||
class="grid min-w-full"
|
||||
style="grid-template-columns: 1fr 150px 200px 80px">
|
||||
<TableHeading>
|
||||
<TableCell
|
||||
v-for="header in table.getHeaderGroups()[0].headers"
|
||||
:key="header.id"
|
||||
:class="
|
||||
header.column.getCanSort()
|
||||
? 'cursor-pointer select-none'
|
||||
: ''
|
||||
"
|
||||
@click="
|
||||
header.column.getToggleSortingHandler()?.($event)
|
||||
"
|
||||
:cell="header">
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()" />
|
||||
<div class="px-1" v-if="header.column.getCanSort()">
|
||||
<ChevronUpDownIcon
|
||||
class="h-4 text-text-tertiary"
|
||||
v-if="
|
||||
header.column.getIsSorted() === false
|
||||
"></ChevronUpDownIcon>
|
||||
<ChevronDownIcon
|
||||
class="h-4 text-accent-300"
|
||||
v-if="
|
||||
header.column.getIsSorted() === 'desc'
|
||||
"></ChevronDownIcon>
|
||||
<ChevronUpIcon
|
||||
class="h-4 text-accent-300"
|
||||
v-if="
|
||||
header.column.getIsSorted() === 'asc'
|
||||
"></ChevronUpIcon>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableHeading>
|
||||
|
||||
<ClientTableHeading></ClientTableHeading>
|
||||
<div
|
||||
class="col-span-2 py-24 text-center"
|
||||
v-if="clients.length === 0">
|
||||
@@ -215,16 +40,9 @@ const sorting = ref<SortingState>([]);
|
||||
>Create your First Client
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<TableRow v-for="row in table.getRowModel().rows" :key="row.id">
|
||||
<TableCell
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:cell="cell">
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<template v-for="client in clients" :key="client.id">
|
||||
<ClientTableRow :client="client"></ClientTableRow>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
69
resources/js/Components/Common/Client/ClientTableRow.vue
Normal file
69
resources/js/Components/Common/Client/ClientTableRow.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import type { Client } from '@/packages/api/src';
|
||||
import { computed, ref } from 'vue';
|
||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import TableRow from '@/Components/TableRow.vue';
|
||||
import ClientEditModal from '@/Components/Common/Client/ClientEditModal.vue';
|
||||
|
||||
const { projects } = storeToRefs(useProjectsStore());
|
||||
|
||||
const props = defineProps<{
|
||||
client: Client;
|
||||
}>();
|
||||
|
||||
function deleteClient() {
|
||||
useClientsStore().deleteClient(props.client.id);
|
||||
}
|
||||
|
||||
const projectCount = computed(() => {
|
||||
return projects.value.filter(
|
||||
(projects) => projects.client_id === props.client.id
|
||||
).length;
|
||||
});
|
||||
|
||||
function archiveClient() {
|
||||
useClientsStore().updateClient(props.client.id, {
|
||||
...props.client,
|
||||
is_archived: !props.client.is_archived,
|
||||
});
|
||||
}
|
||||
|
||||
const showEditModal = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableRow>
|
||||
<ClientEditModal
|
||||
:client="client"
|
||||
v-model:show="showEditModal"></ClientEditModal>
|
||||
<div
|
||||
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<span>
|
||||
{{ client.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<span class="text-muted"> {{ projectCount }} Projects </span>
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-muted flex space-x-1 items-center font-medium">
|
||||
<CheckCircleIcon class="w-5"></CheckCircleIcon>
|
||||
<span>Active</span>
|
||||
</div>
|
||||
<div
|
||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<ClientMoreOptionsDropdown
|
||||
:client="client"
|
||||
@edit="showEditModal = true"
|
||||
@archive="archiveClient"
|
||||
@delete="deleteClient"></ClientMoreOptionsDropdown>
|
||||
</div>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
@@ -12,9 +12,8 @@ import { Link, useForm } from '@inertiajs/vue3';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { filterRoles } from '@/utils/roles';
|
||||
import {
|
||||
hasActiveSubscription,
|
||||
isAllowedToPerformPremiumAction,
|
||||
isBillingActivated,
|
||||
isInTrial,
|
||||
} from '@/utils/billing';
|
||||
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
|
||||
@@ -84,14 +83,6 @@ async function submit() {
|
||||
|
||||
const clientNameInput = ref<HTMLInputElement | null>(null);
|
||||
useFocus(clientNameInput, { initialValue: true });
|
||||
|
||||
const inviteMembersIsAllowed = computed(() => {
|
||||
return (
|
||||
!isBillingActivated() ||
|
||||
(isBillingActivated() && hasActiveSubscription()) ||
|
||||
(isBillingActivated() && isInTrial())
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -103,7 +94,7 @@ const inviteMembersIsAllowed = computed(() => {
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="!inviteMembersIsAllowed">
|
||||
<div v-if="!isAllowedToPerformPremiumAction()">
|
||||
<div
|
||||
class="rounded-full flex items-center justify-center w-20 h-20 mx-auto border border-border-tertiary bg-secondary">
|
||||
<UserGroupIcon class="w-12"></UserGroupIcon>
|
||||
@@ -215,7 +206,7 @@ const inviteMembersIsAllowed = computed(() => {
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
v-if="inviteMembersIsAllowed"
|
||||
v-if="isAllowedToPerformPremiumAction()"
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving"
|
||||
|
||||
@@ -17,10 +17,12 @@ import { useClientsStore } from '@/utils/useClients';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';
|
||||
import { UserCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
|
||||
const { updateProject } = useProjectsStore();
|
||||
const { clients } = storeToRefs(useClientsStore());
|
||||
@@ -41,6 +43,7 @@ const project = ref<CreateProjectBody>({
|
||||
client_id: props.originalProject.client_id,
|
||||
billable_rate: props.originalProject.billable_rate,
|
||||
is_billable: props.originalProject.is_billable,
|
||||
estimated_time: props.originalProject.estimated_time,
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
@@ -127,13 +130,23 @@ async function submitBillableRate() {
|
||||
</ClientDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectEditBillableSection
|
||||
@submit="submit"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
v-model:isBillable="project.is_billable"
|
||||
v-model:billableRate="
|
||||
project.billable_rate
|
||||
"></ProjectEditBillableSection>
|
||||
<div class="lg:grid grid-cols-2 gap-12">
|
||||
<div>
|
||||
<ProjectEditBillableSection
|
||||
@submit="submit"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
v-model:isBillable="project.is_billable"
|
||||
v-model:billableRate="
|
||||
project.billable_rate
|
||||
"></ProjectEditBillableSection>
|
||||
</div>
|
||||
<div>
|
||||
<EstimatedTimeSection
|
||||
v-if="isAllowedToPerformPremiumAction()"
|
||||
@submit="submit()"
|
||||
v-model="project.estimated_time"></EstimatedTimeSection>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||
|
||||
@@ -49,7 +49,18 @@ const { clients } = storeToRefs(useClientsStore());
|
||||
<div
|
||||
data-testid="project_table"
|
||||
class="grid min-w-full"
|
||||
style="grid-template-columns: 1fr 1fr 1fr 150px 80px">
|
||||
style="
|
||||
grid-template-columns:
|
||||
minmax(300px, 1fr) minmax(150px, auto) minmax(
|
||||
140px,
|
||||
auto
|
||||
)
|
||||
minmax(130px, auto) minmax(130px, auto) minmax(
|
||||
120px,
|
||||
auto
|
||||
)
|
||||
80px;
|
||||
">
|
||||
<ProjectTableHeading></ProjectTableHeading>
|
||||
<div
|
||||
class="col-span-5 py-24 text-center"
|
||||
|
||||
@@ -9,6 +9,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
Name
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-white">Client</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-white">
|
||||
Total Time
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-white">
|
||||
Progress
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-white">
|
||||
Billable Rate
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,10 @@ import TableRow from '@/Components/TableRow.vue';
|
||||
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
|
||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
||||
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
|
||||
const { clients } = storeToRefs(useClientsStore());
|
||||
const { tasks } = storeToRefs(useTasksStore());
|
||||
@@ -64,24 +68,42 @@ const showEditProjectModal = ref(false);
|
||||
:original-project="project"></ProjectEditModal>
|
||||
<TableRow :href="route('projects.show', { project: project.id })">
|
||||
<div
|
||||
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<div
|
||||
:style="{
|
||||
backgroundColor: project.color,
|
||||
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
|
||||
}"
|
||||
class="w-3 h-3 rounded-full"></div>
|
||||
<span>
|
||||
<span class="overflow-ellipsis overflow-hidden">
|
||||
{{ project.name }}
|
||||
</span>
|
||||
<span class="text-muted"> {{ projectTasksCount }} Tasks </span>
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
||||
<div v-if="project.client_id">
|
||||
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-muted">
|
||||
<div
|
||||
class="overflow-ellipsis overflow-hidden"
|
||||
v-if="project.client_id">
|
||||
{{ client?.name }}
|
||||
</div>
|
||||
<div v-else>No client</div>
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
||||
<div v-if="project.spent_time">
|
||||
{{ formatHumanReadableDuration(project.spent_time) }}
|
||||
</div>
|
||||
<div v-else>--</div>
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap px-3 flex items-center text-sm text-muted">
|
||||
<UpgradeBadge
|
||||
v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
||||
<EstimatedTimeProgress
|
||||
v-else-if="project.estimated_time"
|
||||
:estimated="project.estimated_time"
|
||||
:current="project.spent_time"></EstimatedTimeProgress>
|
||||
<span v-else> -- </span>
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
||||
{{ billableRateInfo }}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useProjectMembersStore } from '@/utils/useProjectMembers';
|
||||
import MemberCombobox from '@/Components/Common/Member/MemberCombobox.vue';
|
||||
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { InputLabel } from '@/packages/ui/src';
|
||||
import ProjectMemberRoleSelect from '@/Components/Common/ProjectMember/ProjectMemberRoleSelect.vue';
|
||||
const { createProjectMember } = useProjectMembersStore();
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
@@ -24,6 +26,7 @@ const props = defineProps<{
|
||||
const projectMember = ref<CreateProjectMemberBody>({
|
||||
member_id: '',
|
||||
billable_rate: null,
|
||||
role: 'normal',
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
@@ -32,6 +35,7 @@ async function submit() {
|
||||
projectMember.value = {
|
||||
member_id: '',
|
||||
billable_rate: null,
|
||||
role: 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,13 +53,17 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="grid grid-cols-3 items-center space-x-4">
|
||||
<div class="col-span-3 sm:col-span-2">
|
||||
<div class="items-center space-y-4">
|
||||
<div>
|
||||
<InputLabel value="Member" class="mb-2"></InputLabel>
|
||||
<MemberCombobox
|
||||
:hidden-members="props.existingMembers"
|
||||
v-model="projectMember.member_id"></MemberCombobox>
|
||||
</div>
|
||||
<div class="col-span-3 sm:col-span-1 flex-1">
|
||||
<div>
|
||||
<InputLabel
|
||||
value="Billable Rate"
|
||||
for="billable_rate"></InputLabel>
|
||||
<BillableRateInput
|
||||
name="billable_rate"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
@@ -63,6 +71,11 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
projectMember.billable_rate
|
||||
"></BillableRateInput>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel value="Role" class="mb-2"></InputLabel>
|
||||
<ProjectMemberRoleSelect
|
||||
v-model="projectMember.role"></ProjectMemberRoleSelect>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
|
||||
@@ -8,12 +8,15 @@ import type {
|
||||
} from '@/packages/api/src';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { useProjectMembersStore } from '@/utils/useProjectMembers';
|
||||
import {
|
||||
type ProjectMemberRole,
|
||||
useProjectMembersStore,
|
||||
} from '@/utils/useProjectMembers';
|
||||
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
|
||||
import { UserIcon } from '@heroicons/vue/24/solid';
|
||||
import ProjectMemberBillableRateModal from '@/Components/Common/ProjectMember/ProjectMemberBillableRateModal.vue';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import ProjectMemberRoleSelect from '@/Components/Common/ProjectMember/ProjectMemberRoleSelect.vue';
|
||||
const { updateProjectMember } = useProjectMembersStore();
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
@@ -26,6 +29,7 @@ const props = defineProps<{
|
||||
|
||||
const projectMemberBody = ref<UpdateProjectMemberBody>({
|
||||
billable_rate: props.projectMember.billable_rate,
|
||||
role: props.projectMember.role as ProjectMemberRole,
|
||||
});
|
||||
const showBillableRateModal = ref(false);
|
||||
async function submit() {
|
||||
@@ -40,6 +44,7 @@ async function submit() {
|
||||
show.value = false;
|
||||
projectMemberBody.value = {
|
||||
billable_rate: null,
|
||||
role: 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,6 +60,7 @@ watch(
|
||||
if (value) {
|
||||
projectMemberBody.value = {
|
||||
billable_rate: props.projectMember.billable_rate,
|
||||
role: props.projectMember.role as ProjectMemberRole,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -69,7 +75,7 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span>Edit Project Member</span>
|
||||
<span>Edit Project Member "{{ props.name }}"</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -80,23 +86,26 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
:new-billable-rate="projectMemberBody.billable_rate"
|
||||
@close="showBillableRateModal = false"
|
||||
@submit="submitBillableRate"></ProjectMemberBillableRateModal>
|
||||
<div class="grid grid-cols-3 items-center space-x-4">
|
||||
<div
|
||||
class="col-span-3 sm:col-span-2 space-x-2 flex items-center">
|
||||
<UserIcon class="w-4 text-muted"></UserIcon>
|
||||
<span>{{ props.name }}</span>
|
||||
</div>
|
||||
<div class="col-span-3 sm:col-span-1 flex-1">
|
||||
<InputLabel
|
||||
for="billable_rate"
|
||||
value="Billable Rate"></InputLabel>
|
||||
<BillableRateInput
|
||||
@keydown.enter="submit"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
name="billable_rate"
|
||||
v-model="
|
||||
projectMemberBody.billable_rate
|
||||
"></BillableRateInput>
|
||||
<div>
|
||||
<div class="items-center space-y-4">
|
||||
<div>
|
||||
<InputLabel
|
||||
value="Billable Rate"
|
||||
for="billable_rate"></InputLabel>
|
||||
<BillableRateInput
|
||||
name="billable_rate"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
v-model="
|
||||
projectMemberBody.billable_rate
|
||||
"></BillableRateInput>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel value="Role" class="mb-2"></InputLabel>
|
||||
<ProjectMemberRoleSelect
|
||||
v-model="
|
||||
projectMemberBody.role
|
||||
"></ProjectMemberRoleSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
|
||||
import Badge from '@/packages/ui/src/Badge.vue';
|
||||
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||
import type { ProjectMemberRole } from '@/utils/useProjectMembers';
|
||||
|
||||
type ProjectMemberRoleItem = { key: ProjectMemberRole; name: string };
|
||||
|
||||
const projectMemberRoles: ProjectMemberRoleItem[] = [
|
||||
{
|
||||
key: 'normal',
|
||||
name: 'Normal',
|
||||
},
|
||||
{
|
||||
key: 'manager',
|
||||
name: 'Manager',
|
||||
},
|
||||
];
|
||||
|
||||
const model = defineModel<string>({
|
||||
default: 'normal',
|
||||
});
|
||||
|
||||
function getKeyFromItem(item: ProjectMemberRoleItem) {
|
||||
return item.key;
|
||||
}
|
||||
|
||||
function getNameFromItem(item: ProjectMemberRoleItem) {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
function getNameForKey(key: string | undefined) {
|
||||
return projectMemberRoles.find((item) => item.key === key)?.name ?? '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectDropdown
|
||||
v-model="model"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameFromItem"
|
||||
:items="projectMemberRoles">
|
||||
<template #trigger>
|
||||
<Badge size="xlarge" class="bg-input-background cursor-pointer">
|
||||
<span>
|
||||
{{ getNameForKey(model) }}
|
||||
</span>
|
||||
<ChevronDownIcon class="text-muted w-5"></ChevronDownIcon>
|
||||
</Badge>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -57,7 +57,7 @@ const showEditModal = ref(false);
|
||||
}}
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
||||
{{ capitalizeFirstLetter(member?.role ?? '') }}
|
||||
{{ capitalizeFirstLetter(projectMember?.role ?? '') }}
|
||||
</div>
|
||||
<div
|
||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="contents [&>*]:border-row-separator text-xs sm:text-sm [&>*]:border-b [&>*]:py-1 [&>*]:border-t [&>*]:bg-row-heading-background">
|
||||
class="contents [&>*]:border-row-separator text-xs sm:text-sm [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,12 +7,15 @@ import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
import ProjectDropdown from '@/Components/Common/Project/ProjectDropdown.vue';
|
||||
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
|
||||
const { createTask } = useTasksStore();
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
|
||||
const taskName = ref('');
|
||||
const estimatedTime = ref<number | null>(null);
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string;
|
||||
@@ -22,6 +25,7 @@ async function submit() {
|
||||
await createTask({
|
||||
name: taskName.value,
|
||||
project_id: props.projectId,
|
||||
estimated_time: estimatedTime.value,
|
||||
});
|
||||
show.value = false;
|
||||
taskName.value = '';
|
||||
@@ -58,6 +62,10 @@ useFocus(taskNameInput, { initialValue: true });
|
||||
<ProjectDropdown :modelValue="projectId"></ProjectDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<EstimatedTimeSection
|
||||
v-if="isAllowedToPerformPremiumAction()"
|
||||
@submit="submit()"
|
||||
v-model="estimatedTime"></EstimatedTimeSection>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
||||
|
||||
@@ -7,6 +7,8 @@ import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
import type { Task, UpdateTaskBody } from '@/packages/api/src';
|
||||
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
|
||||
const { updateTask } = useTasksStore();
|
||||
const show = defineModel('show', { default: false });
|
||||
@@ -18,6 +20,7 @@ const props = defineProps<{
|
||||
|
||||
const taskBody = ref<UpdateTaskBody>({
|
||||
name: props.task.name,
|
||||
estimated_time: props.task.estimated_time,
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
@@ -34,7 +37,7 @@ useFocus(taskNameInput, { initialValue: true });
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Create Task </span>
|
||||
<span> Update Task </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -53,6 +56,10 @@ useFocus(taskNameInput, { initialValue: true });
|
||||
autocomplete="taskName" />
|
||||
</div>
|
||||
</div>
|
||||
<EstimatedTimeSection
|
||||
v-if="isAllowedToPerformPremiumAction()"
|
||||
@submit="submit()"
|
||||
v-model="taskBody.estimated_time"></EstimatedTimeSection>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
||||
|
||||
@@ -27,7 +27,14 @@ const createTask = ref(false);
|
||||
data-testid="task_table"
|
||||
role="table"
|
||||
class="grid min-w-full"
|
||||
style="grid-template-columns: 1fr 150px 80px">
|
||||
style="
|
||||
grid-template-columns:
|
||||
1fr minmax(80px, auto) minmax(120px, auto) minmax(
|
||||
50px,
|
||||
auto
|
||||
)
|
||||
80px;
|
||||
">
|
||||
<TaskTableHeading></TaskTableHeading>
|
||||
<div
|
||||
class="col-span-5 py-24 text-center"
|
||||
|
||||
@@ -8,6 +8,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
class="py-1.5 pr-3 text-left font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Task Name
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-white">
|
||||
Total Time
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-white">
|
||||
Progress
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-white">Status</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<span class="sr-only">Edit</span>
|
||||
|
||||
@@ -7,6 +7,10 @@ import TableRow from '@/Components/TableRow.vue';
|
||||
import { canDeleteTasks } from '@/utils/permissions';
|
||||
import TaskEditModal from '@/Components/Common/Task/TaskEditModal.vue';
|
||||
import { ref } from 'vue';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
||||
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
||||
|
||||
const props = defineProps<{
|
||||
task: Task;
|
||||
@@ -29,11 +33,28 @@ const showTaskEditModal = ref(false);
|
||||
<template>
|
||||
<TableRow>
|
||||
<div
|
||||
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<span>
|
||||
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<span class="overflow-ellipsis overflow-hidden">
|
||||
{{ task.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-muted flex space-x-1 items-center font-medium">
|
||||
<span v-if="task.spent_time">
|
||||
{{ formatHumanReadableDuration(task.spent_time) }}
|
||||
</span>
|
||||
<span v-else> -- </span>
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap px-3 flex items-center text-sm text-muted">
|
||||
<UpgradeBadge
|
||||
v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
||||
<EstimatedTimeProgress
|
||||
v-else-if="task.estimated_time"
|
||||
:estimated="task.estimated_time"
|
||||
:current="task.spent_time"></EstimatedTimeProgress>
|
||||
<span v-else> -- </span>
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-muted flex space-x-1 items-center font-medium">
|
||||
<template v-if="task.is_done">
|
||||
|
||||
18
resources/js/Components/Common/UpgradeBadge.vue
Normal file
18
resources/js/Components/Common/UpgradeBadge.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
||||
import { ref } from 'vue';
|
||||
const showUpgradeModal = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UpgradeModal v-model:show="showUpgradeModal"></UpgradeModal>
|
||||
<button
|
||||
@click.prevent="showUpgradeModal = true"
|
||||
class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1">
|
||||
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>
|
||||
<span class="text-xs text-text-secondary font-semibold"> Upgrade </span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
62
resources/js/Components/Common/UpgradeModal.vue
Normal file
62
resources/js/Components/Common/UpgradeModal.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
isAllowedToPerformPremiumAction,
|
||||
isBillingActivated,
|
||||
} from '@/utils/billing';
|
||||
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
|
||||
import { SecondaryButton } from '@/packages/ui/src';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Upgrade Plan </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="!isAllowedToPerformPremiumAction()">
|
||||
<div
|
||||
class="rounded-full flex items-center justify-center w-20 h-20 mx-auto border border-border-tertiary bg-secondary">
|
||||
<UserGroupIcon class="w-12"></UserGroupIcon>
|
||||
</div>
|
||||
<div class="max-w-sm text-center mx-auto py-4 text-base">
|
||||
<p class="py-1">
|
||||
<strong>Project and Task Estimates</strong> is only
|
||||
available in solidtime Professional.
|
||||
</p>
|
||||
<p class="py-1">
|
||||
If you want to use this feature,
|
||||
<strong>please upgrade to a paid plan</strong>.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
v-if="isBillingActivated() && canManageBilling()"
|
||||
href="/billing">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
class="mt-6"
|
||||
v-if="
|
||||
isBillingActivated() && canUpdateOrganization()
|
||||
">
|
||||
<CreditCardIcon class="w-5 h-5 me-2" />
|
||||
Go to Billing
|
||||
</PrimaryButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false">Close</SecondaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -4,8 +4,8 @@ import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
@@ -20,16 +20,17 @@ const project = computed(() => {
|
||||
});
|
||||
|
||||
const { currentTimeEntry } = storeToRefs(useCurrentTimeEntryStore());
|
||||
const { stopTimer, startTimer } = useCurrentTimeEntryStore();
|
||||
const { setActiveState } = useCurrentTimeEntryStore();
|
||||
|
||||
async function startTaskTimer() {
|
||||
if (currentTimeEntry.value.id) {
|
||||
await stopTimer();
|
||||
await setActiveState(true);
|
||||
}
|
||||
currentTimeEntry.value.project_id = props.project_id;
|
||||
currentTimeEntry.value.task_id = props.task_id;
|
||||
currentTimeEntry.value.start = dayjs().utc().format();
|
||||
await startTimer();
|
||||
currentTimeEntry.value.start = getDayJsInstance().utc().format();
|
||||
currentTimeEntry.value.billable = project.value?.is_billable ?? false;
|
||||
await setActiveState(true);
|
||||
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -189,7 +189,7 @@ const option = ref({
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<StatCard
|
||||
title="Total Time"
|
||||
title="Spent Time"
|
||||
:value="formatHumanReadableDuration(props.totalWeeklyTime)" />
|
||||
<StatCard
|
||||
title="Billable Time"
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { Cell, Header } from '@tanstack/vue-table';
|
||||
|
||||
defineProps<{
|
||||
cell: Cell<T, unknown> | Header<T, unknown>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
twMerge(
|
||||
'whitespace-nowrap px-3 py-0.5 text-sm text-muted flex space-x-1 items-center font-medium',
|
||||
cell.column.getIndex() === 0 &&
|
||||
'pl-4 sm:pl-6 lg:pl-8 3xl:pl-12',
|
||||
cell.column.getIndex() ===
|
||||
cell.getContext().table.getAllColumns().length - 1 &&
|
||||
'pr-4 sm:pr-6 lg:pr-8 3xl:pr-12'
|
||||
)
|
||||
">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -92,7 +92,7 @@ const page = usePage<{
|
||||
<CurrentSidebarTimer></CurrentSidebarTimer>
|
||||
</div>
|
||||
<div
|
||||
class="overflow-y-scroll flex-1 w-[calc(100%+10px)]"
|
||||
class="overflow-y-scroll flex-1 w-full"
|
||||
style="
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-bg-primary) transparent;
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
ChevronRightIcon,
|
||||
CheckCircleIcon,
|
||||
UserGroupIcon,
|
||||
PencilSquareIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
|
||||
import TaskTable from '@/Components/Common/Task/TaskTable.vue';
|
||||
@@ -19,10 +21,18 @@ import Card from '@/Components/Common/Card.vue';
|
||||
import ProjectMemberTable from '@/Components/Common/ProjectMember/ProjectMemberTable.vue';
|
||||
import ProjectMemberCreateModal from '@/Components/Common/ProjectMember/ProjectMemberCreateModal.vue';
|
||||
import { useProjectMembersStore } from '@/utils/useProjectMembers';
|
||||
import { canCreateTasks, canViewProjectMembers } from '@/utils/permissions';
|
||||
import {
|
||||
canCreateProjects,
|
||||
canCreateTasks,
|
||||
canViewProjectMembers,
|
||||
} from '@/utils/permissions';
|
||||
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
||||
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import { formatCents } from '../packages/ui/src/utils/money';
|
||||
import { getOrganizationCurrencyString } from '../utils/money';
|
||||
|
||||
const { projects } = storeToRefs(useProjectsStore());
|
||||
|
||||
@@ -45,6 +55,8 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const showEditProjectModal = ref(false);
|
||||
|
||||
const activeTab = ref<'active' | 'done'>('active');
|
||||
|
||||
function isActiveTab(tab: string) {
|
||||
@@ -98,7 +110,35 @@ const shownTasks = computed(() => {
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="px-4">
|
||||
<Badge v-if="project?.billable_rate">
|
||||
{{
|
||||
formatCents(
|
||||
project?.billable_rate ?? 0,
|
||||
getOrganizationCurrencyString()
|
||||
)
|
||||
}}
|
||||
/ h
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="project?.is_billable && !project?.billable_rate">
|
||||
Default Rate
|
||||
</Badge>
|
||||
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>
|
||||
</div>
|
||||
</nav>
|
||||
<div>
|
||||
<SecondaryButton
|
||||
:icon="PencilSquareIcon"
|
||||
@click="showEditProjectModal = true"
|
||||
v-if="canCreateProjects()">
|
||||
Edit Project
|
||||
</SecondaryButton>
|
||||
<ProjectEditModal
|
||||
v-if="project"
|
||||
:originalProject="project"
|
||||
v-model:show="showEditProjectModal"></ProjectEditModal>
|
||||
</div>
|
||||
</MainContainer>
|
||||
<MainContainer>
|
||||
<div class="grid lg:grid-cols-2 gap-x-6 pt-6">
|
||||
|
||||
@@ -42,14 +42,14 @@ const loadMoreContainer = ref<HTMLDivElement | null>(null);
|
||||
const isLoadMoreVisible = useElementVisibility(loadMoreContainer);
|
||||
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
||||
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
|
||||
const { stopTimer } = currentTimeEntryStore;
|
||||
const { setActiveState } = currentTimeEntryStore;
|
||||
const { tags } = storeToRefs(useTagsStore());
|
||||
|
||||
async function startTimeEntry(
|
||||
timeEntry: Omit<CreateTimeEntryBody, 'member_id'>
|
||||
) {
|
||||
if (currentTimeEntry.value.id) {
|
||||
await stopTimer();
|
||||
await setActiveState(false);
|
||||
}
|
||||
await createTimeEntry(timeEntry);
|
||||
fetchTimeEntries();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user