mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 12:42:15 +01:00
Compare commits
2 Commits
claude/fix
...
pullreques
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7922af92e2 | ||
|
|
d1d2aedbae |
@@ -22,27 +22,13 @@ class Kernel extends ConsoleKernel
|
|||||||
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
|
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
|
||||||
->everyTenMinutes();
|
->everyTenMinutes();
|
||||||
|
|
||||||
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {
|
$schedule->command('self-host:check-for-update')
|
||||||
// Convert string to a stable integer for seeding
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
||||||
/** @var int $seed Take the first 8 hex chars → 32-bit int */
|
->twiceDaily();
|
||||||
$seed = hexdec(substr(hash('md5', config('app.key')), 0, 8));
|
|
||||||
$seed = abs($seed); // Ensure it's positive
|
|
||||||
mt_srand($seed);
|
|
||||||
$firstHour = mt_rand(0, 23);
|
|
||||||
$secondHour = ($firstHour + 12) % 24;
|
|
||||||
$minuteOffset = mt_rand(0, 59);
|
|
||||||
mt_srand(null); // Reset the random number generator
|
|
||||||
|
|
||||||
if (config('scheduling.tasks.self_hosting_check_for_update')) {
|
$schedule->command('self-host:telemetry')
|
||||||
$schedule->command('self-host:check-for-update')
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
|
||||||
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
->twiceDaily();
|
||||||
}
|
|
||||||
|
|
||||||
if (config('scheduling.tasks.self_hosting_telemetry')) {
|
|
||||||
$schedule->command('self-host:telemetry')
|
|
||||||
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$schedule->command('self-host:database-consistency')
|
$schedule->command('self-host:database-consistency')
|
||||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\TimeEntryResource\Pages;
|
use App\Filament\Resources\TimeEntryResource\Pages;
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@@ -17,7 +16,6 @@ use Filament\Tables;
|
|||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class TimeEntryResource extends Resource
|
class TimeEntryResource extends Resource
|
||||||
{
|
{
|
||||||
@@ -53,20 +51,6 @@ class TimeEntryResource extends Resource
|
|||||||
->rules([
|
->rules([
|
||||||
'after_or_equal:start',
|
'after_or_equal:start',
|
||||||
]),
|
]),
|
||||||
Select::make('organization_id')
|
|
||||||
->relationship(name: 'organization', titleAttribute: 'name')
|
|
||||||
->searchable(['name'])
|
|
||||||
->required(),
|
|
||||||
Select::make('member_id')
|
|
||||||
->relationship(
|
|
||||||
name: 'member',
|
|
||||||
titleAttribute: 'id',
|
|
||||||
modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization'])
|
|
||||||
)
|
|
||||||
->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')')
|
|
||||||
->searchable()
|
|
||||||
->preload()
|
|
||||||
->required(),
|
|
||||||
Select::make('user_id')
|
Select::make('user_id')
|
||||||
->relationship(name: 'user', titleAttribute: 'email')
|
->relationship(name: 'user', titleAttribute: 'email')
|
||||||
->searchable(['name', 'email'])
|
->searchable(['name', 'email'])
|
||||||
@@ -75,10 +59,7 @@ class TimeEntryResource extends Resource
|
|||||||
->relationship(name: 'project', titleAttribute: 'name')
|
->relationship(name: 'project', titleAttribute: 'name')
|
||||||
->searchable(['name'])
|
->searchable(['name'])
|
||||||
->nullable(),
|
->nullable(),
|
||||||
Select::make('task_id')
|
// TODO
|
||||||
->relationship(name: 'task', titleAttribute: 'name')
|
|
||||||
->searchable(['name'])
|
|
||||||
->nullable(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,28 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TimeEntryResource;
|
use App\Filament\Resources\TimeEntryResource;
|
||||||
use App\Models\Member;
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
class CreateTimeEntry extends CreateRecord
|
class CreateTimeEntry extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = TimeEntryResource::class;
|
protected static string $resource = TimeEntryResource::class;
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function mutateFormDataBeforeCreate(array $data): array
|
|
||||||
{
|
|
||||||
if (isset($data['member_id'])) {
|
|
||||||
/** @var Member|null $member */
|
|
||||||
$member = Member::query()->find($data['member_id']);
|
|
||||||
if ($member !== null) {
|
|
||||||
$data['user_id'] = $member->user_id;
|
|
||||||
$data['organization_id'] = $member->organization_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TimeEntryResource;
|
use App\Filament\Resources\TimeEntryResource;
|
||||||
use App\Models\Member;
|
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
@@ -20,22 +19,4 @@ class EditTimeEntry extends EditRecord
|
|||||||
->icon('heroicon-m-trash'),
|
->icon('heroicon-m-trash'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function mutateFormDataBeforeSave(array $data): array
|
|
||||||
{
|
|
||||||
if (isset($data['member_id'])) {
|
|
||||||
/** @var Member|null $member */
|
|
||||||
$member = Member::query()->find($data['member_id']);
|
|
||||||
if ($member !== null) {
|
|
||||||
$data['user_id'] = $member->user_id;
|
|
||||||
$data['organization_id'] = $member->organization_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,6 @@ class OrganizationController extends Controller
|
|||||||
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
||||||
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
||||||
}
|
}
|
||||||
if ($request->getEmployeesCanManageTasks() !== null) {
|
|
||||||
$organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks();
|
|
||||||
}
|
|
||||||
if ($request->getNumberFormat() !== null) {
|
if ($request->getNumberFormat() !== null) {
|
||||||
$organization->number_format = $request->getNumberFormat();
|
$organization->number_format = $request->getNumberFormat();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest;
|
|||||||
use App\Http\Resources\V1\Task\TaskCollection;
|
use App\Http\Resources\V1\Task\TaskCollection;
|
||||||
use App\Http\Resources\V1\Task\TaskResource;
|
use App\Http\Resources\V1\Task\TaskResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -28,26 +27,6 @@ class TaskController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check scoped permission and verify user has access to the project
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*/
|
|
||||||
private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, $permission);
|
|
||||||
|
|
||||||
$user = $this->user();
|
|
||||||
$hasAccess = Project::query()
|
|
||||||
->where('id', $project->id)
|
|
||||||
->visibleByEmployee($user)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if (! $hasAccess) {
|
|
||||||
throw new AuthorizationException('You do not have permission to '.$permission.' in this project.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tasks
|
* Get tasks
|
||||||
*
|
*
|
||||||
@@ -96,15 +75,7 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
|
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
/** @var Project $project */
|
$this->checkPermission($organization, 'tasks:create');
|
||||||
$project = Project::query()->findOrFail($request->input('project_id'));
|
|
||||||
|
|
||||||
if ($this->hasPermission($organization, 'tasks:create:all')) {
|
|
||||||
$this->checkPermission($organization, 'tasks:create:all');
|
|
||||||
} else {
|
|
||||||
$this->checkScopedPermissionForProject($organization, $project, 'tasks:create');
|
|
||||||
}
|
|
||||||
|
|
||||||
$task = new Task;
|
$task = new Task;
|
||||||
$task->name = $request->input('name');
|
$task->name = $request->input('name');
|
||||||
$task->project_id = $request->input('project_id');
|
$task->project_id = $request->input('project_id');
|
||||||
@@ -126,17 +97,7 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
|
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
// Check task belongs to organization
|
$this->checkPermission($organization, 'tasks:update', $task);
|
||||||
if ($task->organization_id !== $organization->id) {
|
|
||||||
throw new AuthorizationException('Task does not belong to organization');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->hasPermission($organization, 'tasks:update:all')) {
|
|
||||||
$this->checkPermission($organization, 'tasks:update:all');
|
|
||||||
} else {
|
|
||||||
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update');
|
|
||||||
}
|
|
||||||
|
|
||||||
$task->name = $request->input('name');
|
$task->name = $request->input('name');
|
||||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||||
$task->estimated_time = $request->getEstimatedTime();
|
$task->estimated_time = $request->getEstimatedTime();
|
||||||
@@ -158,16 +119,7 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(Organization $organization, Task $task): JsonResponse
|
public function destroy(Organization $organization, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
// Check task belongs to organization
|
$this->checkPermission($organization, 'tasks:delete', $task);
|
||||||
if ($task->organization_id !== $organization->id) {
|
|
||||||
throw new AuthorizationException('Task does not belong to organization');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->hasPermission($organization, 'tasks:delete:all')) {
|
|
||||||
$this->checkPermission($organization, 'tasks:delete:all');
|
|
||||||
} else {
|
|
||||||
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($task->timeEntries()->exists()) {
|
if ($task->timeEntries()->exists()) {
|
||||||
throw new EntityStillInUseApiException('task', 'time_entry');
|
throw new EntityStillInUseApiException('task', 'time_entry');
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||||
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
||||||
|
|
||||||
/** @var BillingContract $billing */
|
/** @var BillingContract $billing */
|
||||||
$billing = app(BillingContract::class);
|
$billing = app(BillingContract::class);
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
'employees_can_see_billable_rates' => [
|
'employees_can_see_billable_rates' => [
|
||||||
'boolean',
|
'boolean',
|
||||||
],
|
],
|
||||||
'employees_can_manage_tasks' => [
|
|
||||||
'boolean',
|
|
||||||
],
|
|
||||||
'prevent_overlapping_time_entries' => [
|
'prevent_overlapping_time_entries' => [
|
||||||
'boolean',
|
'boolean',
|
||||||
],
|
],
|
||||||
@@ -105,11 +102,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEmployeesCanManageTasks(): ?bool
|
|
||||||
{
|
|
||||||
return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPreventOverlappingTimeEntries(): ?bool
|
public function getPreventOverlappingTimeEntries(): ?bool
|
||||||
{
|
{
|
||||||
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
|
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Service\PermissionStore;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,16 +42,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
|||||||
'required_with:task_id',
|
'required_with:task_id',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
|
||||||
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
|
||||||
$permissionStore = app(PermissionStore::class);
|
|
||||||
if (! $permissionStore->has($this->organization, 'time-entries:create:all')
|
|
||||||
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
|
||||||
$builder = $builder->visibleByEmployee(Auth::user());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $builder;
|
|
||||||
})->uuid(),
|
})->uuid(),
|
||||||
],
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
@@ -90,7 +79,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
|||||||
'description' => [
|
'description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:5000',
|
'max:500',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'tags' => [
|
'tags' => [
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Service\PermissionStore;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,16 +54,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
|||||||
'required_with:task_id',
|
'required_with:task_id',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
|
||||||
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
|
||||||
$permissionStore = app(PermissionStore::class);
|
|
||||||
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
|
|
||||||
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
|
||||||
$builder = $builder->visibleByEmployee(Auth::user());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $builder;
|
|
||||||
})->uuid(),
|
})->uuid(),
|
||||||
],
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
@@ -90,7 +79,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
|||||||
'changes.description' => [
|
'changes.description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:5000',
|
'max:500',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'changes.tags' => [
|
'changes.tags' => [
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Service\PermissionStore;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,16 +42,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
|||||||
'required_with:task_id',
|
'required_with:task_id',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
|
||||||
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
|
||||||
$permissionStore = app(PermissionStore::class);
|
|
||||||
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
|
|
||||||
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
|
||||||
$builder = $builder->visibleByEmployee(Auth::user());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $builder;
|
|
||||||
})->uuid(),
|
})->uuid(),
|
||||||
],
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
@@ -88,7 +77,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
|||||||
'description' => [
|
'description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:5000',
|
'max:500',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'tags' => [
|
'tags' => [
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ class OrganizationResource extends BaseResource
|
|||||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||||
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
||||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||||
/** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
|
||||||
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
|
|
||||||
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
|
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
|
||||||
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
|
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
|
||||||
/** @var string $currency Currency code (ISO 4217) */
|
/** @var string $currency Currency code (ISO 4217) */
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property int|null $billable_rate
|
* @property int|null $billable_rate
|
||||||
* @property string $user_id
|
* @property string $user_id
|
||||||
* @property bool $employees_can_see_billable_rates
|
* @property bool $employees_can_see_billable_rates
|
||||||
* @property bool $employees_can_manage_tasks
|
|
||||||
* @property User $owner
|
* @property User $owner
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
@@ -71,7 +70,6 @@ class Organization extends JetstreamTeam implements AuditableContract
|
|||||||
'personal_team' => 'boolean',
|
'personal_team' => 'boolean',
|
||||||
'currency' => 'string',
|
'currency' => 'string',
|
||||||
'employees_can_see_billable_rates' => 'boolean',
|
'employees_can_see_billable_rates' => 'boolean',
|
||||||
'employees_can_manage_tasks' => 'boolean',
|
|
||||||
'prevent_overlapping_time_entries' => 'boolean',
|
'prevent_overlapping_time_entries' => 'boolean',
|
||||||
'number_format' => NumberFormat::class,
|
'number_format' => NumberFormat::class,
|
||||||
'currency_format' => CurrencyFormat::class,
|
'currency_format' => CurrencyFormat::class,
|
||||||
|
|||||||
@@ -94,11 +94,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tasks:view',
|
'tasks:view',
|
||||||
'tasks:view:all',
|
'tasks:view:all',
|
||||||
'tasks:create',
|
'tasks:create',
|
||||||
'tasks:create:all',
|
|
||||||
'tasks:update',
|
'tasks:update',
|
||||||
'tasks:update:all',
|
|
||||||
'tasks:delete',
|
'tasks:delete',
|
||||||
'tasks:delete:all',
|
|
||||||
'time-entries:view:all',
|
'time-entries:view:all',
|
||||||
'time-entries:create:all',
|
'time-entries:create:all',
|
||||||
'time-entries:update:all',
|
'time-entries:update:all',
|
||||||
@@ -161,11 +158,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tasks:view',
|
'tasks:view',
|
||||||
'tasks:view:all',
|
'tasks:view:all',
|
||||||
'tasks:create',
|
'tasks:create',
|
||||||
'tasks:create:all',
|
|
||||||
'tasks:update',
|
'tasks:update',
|
||||||
'tasks:update:all',
|
|
||||||
'tasks:delete',
|
'tasks:delete',
|
||||||
'tasks:delete:all',
|
|
||||||
'time-entries:view:all',
|
'time-entries:view:all',
|
||||||
'time-entries:create:all',
|
'time-entries:create:all',
|
||||||
'time-entries:update:all',
|
'time-entries:update:all',
|
||||||
@@ -225,11 +219,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tasks:view',
|
'tasks:view',
|
||||||
'tasks:view:all',
|
'tasks:view:all',
|
||||||
'tasks:create',
|
'tasks:create',
|
||||||
'tasks:create:all',
|
|
||||||
'tasks:update',
|
'tasks:update',
|
||||||
'tasks:update:all',
|
|
||||||
'tasks:delete',
|
'tasks:delete',
|
||||||
'tasks:delete:all',
|
|
||||||
'time-entries:view:all',
|
'time-entries:view:all',
|
||||||
'time-entries:create:all',
|
'time-entries:create:all',
|
||||||
'time-entries:update:all',
|
'time-entries:update:all',
|
||||||
|
|||||||
@@ -266,8 +266,7 @@ class DashboardService
|
|||||||
) as aggregate'))
|
) as aggregate'))
|
||||||
->where('billable', '=', true)
|
->where('billable', '=', true)
|
||||||
->whereNotNull('billable_rate')
|
->whereNotNull('billable_rate')
|
||||||
->where('user_id', '=', $user->getKey())
|
->where('user_id', '=', $user->id);
|
||||||
->where('organization_id', '=', $organization->getKey());
|
|
||||||
|
|
||||||
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
||||||
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ class ExportService
|
|||||||
$client->id,
|
$client->id,
|
||||||
$client->name,
|
$client->name,
|
||||||
$client->organization_id,
|
$client->organization_id,
|
||||||
$client->archived_at?->toIso8601ZuluString() ?? '',
|
$client->archived_at ?? '',
|
||||||
$client->created_at?->toIso8601ZuluString() ?? '',
|
$client->created_at?->toIso8601ZuluString() ?? '',
|
||||||
$client->updated_at?->toIso8601ZuluString() ?? '',
|
$client->updated_at?->toIso8601ZuluString() ?? '',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
$timeEntry->project_id = $projectId;
|
$timeEntry->project_id = $projectId;
|
||||||
$timeEntry->client_id = $clientId;
|
$timeEntry->client_id = $clientId;
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
$timeEntry->organization_id = $this->organization->id;
|
||||||
if (strlen($record['Description']) > 5000) {
|
if (strlen($record['Description']) > 500) {
|
||||||
throw new ImportException('Time entry description is too long');
|
throw new ImportException('Time entry description is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $record['Description'];
|
$timeEntry->description = $record['Description'];
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
|||||||
$timeEntry->project_id = $projectId;
|
$timeEntry->project_id = $projectId;
|
||||||
$timeEntry->client_id = $clientId;
|
$timeEntry->client_id = $clientId;
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
$timeEntry->organization_id = $this->organization->id;
|
||||||
if (strlen($record['Notes']) > 5000) {
|
if (strlen($record['Notes']) > 500) {
|
||||||
throw new ImportException('Time entry note is too long');
|
throw new ImportException('Time entry note is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $record['Notes'];
|
$timeEntry->description = $record['Notes'];
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$timeEntry->project_id = $projectId;
|
$timeEntry->project_id = $projectId;
|
||||||
$timeEntry->client_id = $clientId;
|
$timeEntry->client_id = $clientId;
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
$timeEntry->organization_id = $this->organization->id;
|
||||||
if (strlen($timeEntryRow['description']) > 5000) {
|
if (strlen($timeEntryRow['description']) > 500) {
|
||||||
throw new ImportException('Time entry description is too long');
|
throw new ImportException('Time entry description is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $timeEntryRow['description'];
|
$timeEntry->description = $timeEntryRow['description'];
|
||||||
|
|||||||
@@ -71,19 +71,7 @@ class PermissionStore
|
|||||||
/** @var Role|null $roleObj */
|
/** @var Role|null $roleObj */
|
||||||
$roleObj = Jetstream::findRole($role);
|
$roleObj = Jetstream::findRole($role);
|
||||||
|
|
||||||
$permissions = $roleObj->permissions ?? [];
|
return $roleObj->permissions ?? [];
|
||||||
|
|
||||||
// If the organization allows employees to manage tasks and the user is an employee,
|
|
||||||
// add the task management permissions for accessible projects
|
|
||||||
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
|
|
||||||
$permissions = array_merge($permissions, [
|
|
||||||
'tasks:create',
|
|
||||||
'tasks:update',
|
|
||||||
'tasks:delete',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $permissions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,17 +31,12 @@ class TimeEntryService
|
|||||||
throw new LogicException('Rounding minutes must be greater than 0');
|
throw new LogicException('Rounding minutes must be greater than 0');
|
||||||
}
|
}
|
||||||
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
||||||
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
|
|
||||||
if ($roundingType === TimeEntryRoundingType::Down) {
|
if ($roundingType === TimeEntryRoundingType::Down) {
|
||||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
|
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||||
} elseif ($roundingType === TimeEntryRoundingType::Up) {
|
} elseif ($roundingType === TimeEntryRoundingType::Up) {
|
||||||
// If end is already on a boundary, keep it; otherwise round up to next boundary
|
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||||
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
|
|
||||||
'THEN '.$end.' '.
|
|
||||||
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
|
|
||||||
'END';
|
|
||||||
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
|
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
|
||||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
|
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<?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('time_entries', function (Blueprint $table): void {
|
|
||||||
$table->string('description', 5000)->change();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('time_entries', function (Blueprint $table): void {
|
|
||||||
$table->string('description', 500)->change();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<?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('organizations', function (Blueprint $table): void {
|
|
||||||
$table->boolean('employees_can_manage_tasks')->default(false)->after('employees_can_see_billable_rates');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('organizations', function (Blueprint $table): void {
|
|
||||||
$table->dropColumn('employees_can_manage_tasks');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -435,7 +435,7 @@ CREATE TABLE public.tasks (
|
|||||||
|
|
||||||
CREATE TABLE public.time_entries (
|
CREATE TABLE public.time_entries (
|
||||||
id uuid NOT NULL,
|
id uuid NOT NULL,
|
||||||
description character varying(5000) NOT NULL,
|
description character varying(500) NOT NULL,
|
||||||
start timestamp(0) without time zone NOT NULL,
|
start timestamp(0) without time zone NOT NULL,
|
||||||
"end" timestamp(0) without time zone,
|
"end" timestamp(0) without time zone,
|
||||||
billable_rate integer,
|
billable_rate integer,
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ services:
|
|||||||
- sail
|
- sail
|
||||||
- reverse-proxy
|
- reverse-proxy
|
||||||
playwright:
|
playwright:
|
||||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
image: mcr.microsoft.com/playwright:v1.51.1-jammy
|
||||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||||
working_dir: /src
|
working_dir: /src
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ async function goToOrganizationSettings(page) {
|
|||||||
|
|
||||||
async function createTimeEntry(page, duration: string) {
|
async function createTimeEntry(page, duration: string) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||||
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
// Open the dropdown menu and click "Manual time entry"
|
|
||||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
|
||||||
|
|
||||||
// Fill in the time entry details
|
// Fill in the time entry details
|
||||||
await page.getByTestId('time_entry_description').fill('Test time entry');
|
await page.getByTestId('time_entry_description').fill('Test time entry');
|
||||||
|
|||||||
@@ -8,13 +8,6 @@ async function goToProjectsOverview(page: Page) {
|
|||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to clear localStorage before tests that check persistence
|
|
||||||
async function clearProjectTableState(page: Page) {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
localStorage.removeItem('project-table-state');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new project via modal
|
// Create new project via modal
|
||||||
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
|
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
|
||||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
@@ -52,62 +45,34 @@ test('test that creating and deleting a new project via the modal works', async
|
|||||||
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
|
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to select a status filter using the new dropdown UI
|
|
||||||
async function selectStatusFilter(page: Page, status: 'Active' | 'Archived') {
|
|
||||||
// Click the Filter button to open the dropdown
|
|
||||||
await page.getByRole('button', { name: 'Filter projects' }).click();
|
|
||||||
// Click on Status submenu
|
|
||||||
await page.getByRole('menuitem', { name: 'Status' }).click();
|
|
||||||
// Select the status option
|
|
||||||
await page.getByRole('menuitem', { name: status }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to remove status filter by clicking the X on the badge
|
|
||||||
async function removeStatusFilter(page: Page) {
|
|
||||||
const statusBadge = page.getByTestId('status-filter-badge');
|
|
||||||
// Click the remove button (second button in the badge, contains XMarkIcon)
|
|
||||||
await statusBadge.locator('button').last().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
test('test that archiving and unarchiving projects works', async ({ page }) => {
|
test('test that archiving and unarchiving projects works', async ({ page }) => {
|
||||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
await clearProjectTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
await page.getByLabel('Project Name').fill(newProjectName);
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
|
|
||||||
// Archive the project
|
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
await page.getByRole('menuitem').getByText('Archive').first().click();
|
await Promise.all([
|
||||||
|
page.getByRole('menuitem').getByText('Archive').first().click(),
|
||||||
|
expect(page.getByText(newProjectName)).not.toBeVisible(),
|
||||||
|
]);
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('tab', { name: 'Archived' }).click(),
|
||||||
|
expect(page.getByText(newProjectName)).toBeVisible(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Project should still be visible since default is "all" (no filter)
|
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
|
||||||
|
|
||||||
// Apply Active filter - archived project should disappear
|
|
||||||
await selectStatusFilter(page, 'Active');
|
|
||||||
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
|
||||||
|
|
||||||
// Remove Active filter and apply Archived filter
|
|
||||||
await removeStatusFilter(page);
|
|
||||||
await selectStatusFilter(page, 'Archived');
|
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
|
||||||
|
|
||||||
// Unarchive the project
|
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
await page.getByRole('menuitem').getByText('Unarchive').first().click();
|
await Promise.all([
|
||||||
|
page.getByRole('menuitem').getByText('Unarchive').first().click(),
|
||||||
// Project should disappear from Archived view
|
expect(page.getByText(newProjectName)).not.toBeVisible(),
|
||||||
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
]);
|
||||||
|
await Promise.all([
|
||||||
// Remove Archived filter and apply Active filter to see the project
|
page.getByRole('tab', { name: 'Active' }).click(),
|
||||||
await removeStatusFilter(page);
|
expect(page.getByText(newProjectName)).toBeVisible(),
|
||||||
await selectStatusFilter(page, 'Active');
|
]);
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that updating billable rate works with existing time entries', async ({ page }) => {
|
test('test that updating billable rate works with existing time entries', async ({ page }) => {
|
||||||
@@ -151,147 +116,6 @@ test('test that updating billable rate works with existing time entries', async
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sorting tests
|
|
||||||
test('test that sorting projects by name works', async ({ page }) => {
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await clearProjectTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Wait for the table to load
|
|
||||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
|
||||||
|
|
||||||
// Get initial project names
|
|
||||||
const getProjectNames = async () => {
|
|
||||||
const rows = page
|
|
||||||
.getByTestId('project_table')
|
|
||||||
.locator('[data-testid="project_table"] > div')
|
|
||||||
.filter({ hasNot: page.locator('.border-t') });
|
|
||||||
const names: string[] = [];
|
|
||||||
const count = await page.getByTestId('project_table').getByRole('row').count();
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const row = page.getByTestId('project_table').getByRole('row').nth(i);
|
|
||||||
const nameCell = row.locator('div').first();
|
|
||||||
const text = await nameCell.textContent();
|
|
||||||
if (text) {
|
|
||||||
names.push(text.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return names;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Click on Name header to sort ascending (default should already be ascending)
|
|
||||||
const nameHeader = page.getByText('Name').first();
|
|
||||||
await nameHeader.click();
|
|
||||||
|
|
||||||
// Wait for sort to apply
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
|
|
||||||
// Click again to sort descending
|
|
||||||
await nameHeader.click();
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
|
|
||||||
// Verify the sort indicator is showing descending
|
|
||||||
await expect(page.locator('svg').first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that sorting projects by status works', async ({ page }) => {
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await clearProjectTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Default is "all" so no filter needed - Wait for the table to load
|
|
||||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
|
||||||
|
|
||||||
// Click on Status header to sort
|
|
||||||
const statusHeader = page.getByText('Status').first();
|
|
||||||
await statusHeader.click();
|
|
||||||
|
|
||||||
// Wait for sort to apply
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
|
|
||||||
// Sort indicator should be visible
|
|
||||||
await expect(statusHeader.locator('svg')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter tests
|
|
||||||
test('test that filtering projects by status works', async ({ page }) => {
|
|
||||||
const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000);
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await clearProjectTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Create a new project
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
|
||||||
await page.getByLabel('Project Name').fill(newProjectName);
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
|
||||||
|
|
||||||
// Archive the project
|
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
|
||||||
await page.getByRole('menuitem').getByText('Archive').first().click();
|
|
||||||
|
|
||||||
// Project should still be visible (default is "all" - no filter)
|
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
|
||||||
|
|
||||||
// Apply Active filter - archived project should disappear
|
|
||||||
await selectStatusFilter(page, 'Active');
|
|
||||||
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
|
||||||
|
|
||||||
// Remove Active filter - project should reappear (back to "all")
|
|
||||||
await removeStatusFilter(page);
|
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
|
||||||
|
|
||||||
// Apply Archived filter - project should still be visible
|
|
||||||
await selectStatusFilter(page, 'Archived');
|
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
|
||||||
|
|
||||||
// Remove Archived filter and apply Active filter - project should not be visible
|
|
||||||
await removeStatusFilter(page);
|
|
||||||
await selectStatusFilter(page, 'Active');
|
|
||||||
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that filter state persists after page reload', async ({ page }) => {
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await clearProjectTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Apply Active status filter
|
|
||||||
await selectStatusFilter(page, 'Active');
|
|
||||||
|
|
||||||
// Verify the filter badge is visible
|
|
||||||
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
|
|
||||||
|
|
||||||
// Wait for the state to be saved
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
|
|
||||||
// Reload the page
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Verify the filter badge is still visible after reload
|
|
||||||
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that sort state persists after page reload', async ({ page }) => {
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await clearProjectTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Click on Name header twice to sort descending
|
|
||||||
const nameHeader = page.getByText('Name').first();
|
|
||||||
await nameHeader.click();
|
|
||||||
await nameHeader.click();
|
|
||||||
|
|
||||||
// Wait for the state to be saved
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
|
|
||||||
// Reload the page
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Verify descending sort indicator is visible on Name column
|
|
||||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create new project with new Client
|
// Create new project with new Client
|
||||||
|
|
||||||
// Create new project with existing Client
|
// Create new project with existing Client
|
||||||
@@ -300,6 +124,8 @@ test('test that sort state persists after page reload', async ({ page }) => {
|
|||||||
|
|
||||||
// Test that project task count is displayed correctly
|
// Test that project task count is displayed correctly
|
||||||
|
|
||||||
|
// Test that active / archive / all filter works (once implemented)
|
||||||
|
|
||||||
// Edit Project Modal Test
|
// Edit Project Modal Test
|
||||||
|
|
||||||
// Add Project with billable rate
|
// Add Project with billable rate
|
||||||
|
|||||||
@@ -26,10 +26,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
|||||||
|
|
||||||
// Then create the time entry
|
// Then create the time entry
|
||||||
await goToTimeOverview(page);
|
await goToTimeOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
// Open the dropdown menu and click "Manual time entry"
|
|
||||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
|
||||||
|
|
||||||
// Fill in the time entry details
|
// Fill in the time entry details
|
||||||
await page
|
await page
|
||||||
@@ -55,10 +52,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
|||||||
|
|
||||||
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
||||||
await goToTimeOverview(page);
|
await goToTimeOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
// Open the dropdown menu and click "Manual time entry"
|
|
||||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
|
||||||
|
|
||||||
// Fill in the time entry details
|
// Fill in the time entry details
|
||||||
await page
|
await page
|
||||||
@@ -87,10 +81,7 @@ async function createTimeEntryWithBillableStatus(
|
|||||||
duration: string
|
duration: string
|
||||||
) {
|
) {
|
||||||
await goToTimeOverview(page);
|
await goToTimeOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
// Open the dropdown menu and click "Manual time entry"
|
|
||||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
|
||||||
|
|
||||||
// Fill in the time entry details
|
// Fill in the time entry details
|
||||||
await page
|
await page
|
||||||
|
|||||||
@@ -1,14 +1,240 @@
|
|||||||
/* Import shared solidtime styles from UI package */
|
@tailwind base;
|
||||||
@import '../js/packages/ui/styles.css';
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
:root.dark {
|
||||||
|
--color-bg-primary: #101012;
|
||||||
|
--color-bg-secondary: #17181B;
|
||||||
|
--color-bg-tertiary: #2A2C32;
|
||||||
|
--color-bg-quaternary: #141518;
|
||||||
|
--color-bg-background: #090909;
|
||||||
|
--color-text-primary: #ffffff;
|
||||||
|
--color-text-secondary: #e3e4e6;
|
||||||
|
--color-text-tertiary: #969799;
|
||||||
|
--color-text-quaternary: #595a5c;
|
||||||
|
|
||||||
/* Main app specific styles - Inter font */
|
--color-border-primary: #191b1f;
|
||||||
|
--color-border-secondary: #23252a;
|
||||||
|
--color-border-tertiary: #2c2e33;
|
||||||
|
--color-border-quaternary: #393B42;
|
||||||
|
--color-input-border-active: rgba(255,255,255,0.3);
|
||||||
|
|
||||||
|
--theme-color-chart: var(--color-accent-200);
|
||||||
|
|
||||||
|
--theme-color-menu-active: var(--color-bg-secondary);
|
||||||
|
--theme-color-card-background: var(--color-bg-secondary);
|
||||||
|
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
|
||||||
|
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
|
||||||
|
|
||||||
|
--theme-color-card-background-active: var(--color-bg-tertiary);
|
||||||
|
|
||||||
|
--theme-color-row-background: var(--color-bg-primary);
|
||||||
|
--theme-color-row-heading-background: var(--theme-color-card-background);
|
||||||
|
--theme-color-row-heading-border: var(--theme-color-card-border);
|
||||||
|
--theme-color-icon-default: var(--color-text-tertiary);
|
||||||
|
|
||||||
|
--theme-color-ring: rgba(255,255,255,0.5);
|
||||||
|
|
||||||
|
--theme-color-button-primary-background: rgba(var(--color-accent-300), 0.1);
|
||||||
|
--theme-color-button-primary-background-hover: rgba(var(--color-accent-300), 0.2);
|
||||||
|
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
|
||||||
|
--theme-color-button-primary-text: var(--color-text-primary);
|
||||||
|
|
||||||
|
--theme-color-input-background: var(--color-bg-secondary);
|
||||||
|
|
||||||
|
--theme-color-input-select-active: rgb(var(--color-accent-300));
|
||||||
|
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
|
||||||
|
|
||||||
|
--color-accent-default: rgba(var(--color-accent-300), 0.2);
|
||||||
|
--color-accent-foreground: rgb(var(--color-accent-100));
|
||||||
|
--theme-color-default-background: var(--color-bg-primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.light {
|
||||||
|
--color-bg-primary: #FFFFFF;
|
||||||
|
--color-bg-secondary: #f7f7f8;
|
||||||
|
--color-bg-tertiary: #eeeeef;
|
||||||
|
--color-bg-quaternary: #e1e1e3;
|
||||||
|
--color-bg-background: #F5F5F5;
|
||||||
|
--color-text-primary: #18181b;
|
||||||
|
--color-text-secondary: #3f3f46;
|
||||||
|
--color-text-tertiary: #57575C;
|
||||||
|
--color-text-quaternary: #a1a1aa;
|
||||||
|
--color-border-primary: #e7e7e7;
|
||||||
|
--color-border-secondary: #e5e5e5;
|
||||||
|
--color-border-tertiary: #dfdfdf;
|
||||||
|
--color-border-quaternary: #d1d1d1;
|
||||||
|
--color-input-border-active: rgba(0,0,0,0.3);
|
||||||
|
--theme-color-menu-active: var(--color-bg-quaternary);
|
||||||
|
|
||||||
|
--theme-color-card-background: var(--color-bg-primary);
|
||||||
|
--theme-color-card-background-active: var(--color-bg-tertiary);
|
||||||
|
|
||||||
|
--theme-color-chart: var(--color-accent-400);
|
||||||
|
|
||||||
|
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
||||||
|
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
|
--theme-color-row-background: var(--theme-color-card-background);
|
||||||
|
--theme-color-row-heading-background: var(--color-bg-secondary);
|
||||||
|
--theme-color-row-heading-border: var(--color-border-tertiary);
|
||||||
|
--theme-color-icon-default: var(--color-text-quaternary);
|
||||||
|
|
||||||
|
--theme-color-ring: rgba(0,0,0, 0.7);
|
||||||
|
|
||||||
|
--theme-color-button-primary-background: rgba(var(--color-accent-600), 0.9);
|
||||||
|
--theme-color-button-primary-background-hover: rgba(var(--color-accent-600), 1);
|
||||||
|
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
|
||||||
|
--theme-color-button-primary-text: #FFFFFF;
|
||||||
|
|
||||||
|
--theme-color-input-background: var(--color-bg-primary);
|
||||||
|
|
||||||
|
--theme-color-input-select-active: rgb(var(--color-accent-400));
|
||||||
|
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
|
||||||
|
|
||||||
|
--color-accent-default: rgb(var(--color-accent-100));
|
||||||
|
--color-accent-foreground: rgb(var(--color-accent-800));
|
||||||
|
--theme-color-default-background: #FCFCFC;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--theme-color-icon-active: rgb(var(--color-text-tertiary));
|
||||||
|
--theme-color-card-background-separator: var(--color-border-tertiary);
|
||||||
|
--theme-color-card-border: var(--color-border-secondary);
|
||||||
|
--theme-color-card-border-active: var(--color-border-tertiary);
|
||||||
|
--theme-color-default-background-separator: var(--color-border-primary);
|
||||||
|
--theme-color-primary-text: var(--color-text-primary);
|
||||||
|
--theme-color-input-border: var(--color-border-quaternary);
|
||||||
|
--theme-color-tab-background: var(--theme-color-card-background);
|
||||||
|
--theme-color-tab-background-active: var(--theme-color-card-background-active);
|
||||||
|
--theme-color-tab-border: var(--theme-color-card-border);
|
||||||
|
--theme-color-row-separator-background: var(--theme-color-default-background-separator);
|
||||||
|
--theme-color-row-border: var(--theme-color-card-border);
|
||||||
|
|
||||||
|
--color-accent-50: 240, 249, 255; /* sky-50 */
|
||||||
|
--color-accent-100: 224, 242, 254; /* sky-100 */
|
||||||
|
--color-accent-200: 186, 230, 253; /* sky-200 */
|
||||||
|
--color-accent-300: 125, 211, 252; /* sky-300 */
|
||||||
|
--color-accent-400: 56, 189, 248; /* sky-400 */
|
||||||
|
--color-accent-500: 14, 165, 233; /* sky-500 */
|
||||||
|
--color-accent-600: 2, 132, 199; /* sky-600 */
|
||||||
|
--color-accent-700: 3, 105, 161; /* sky-700 */
|
||||||
|
--color-accent-800: 7, 89, 133; /* sky-800 */
|
||||||
|
--color-accent-900: 12, 74, 110; /* sky-900 */
|
||||||
|
--color-accent-950: 8, 47, 73; /* sky-950 */
|
||||||
|
|
||||||
|
--theme-button-secondary-background: var(--theme-color-card-background);
|
||||||
|
--theme-button-secondary-background-active: var(--theme-color-card-background-active);
|
||||||
|
--popover-border: var(--color-border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* width */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track */
|
||||||
|
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle */
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle on hover */
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
[x-cloak] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: var(--theme-color-default-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Inter Variable Font with browser compatibility considerations */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
src:
|
src: url('/fonts/InterVariable.woff2') format('woff2'),
|
||||||
url('/fonts/InterVariable.woff2') format('woff2'),
|
url('/fonts/InterVariable.ttf') format('truetype');
|
||||||
url('/fonts/InterVariable.ttf') format('truetype');
|
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: var(--color-bg-background);
|
||||||
|
--foreground: var(--color-text-primary);
|
||||||
|
--card: var(--theme-color-card-background);
|
||||||
|
--card-foreground: var(--color-text-primary);
|
||||||
|
--popover: var(--theme-color-card-background);
|
||||||
|
--popover-foreground: var(--color-text-primary);
|
||||||
|
--primary: var(--theme-color-button-primary-background);
|
||||||
|
--primary-foreground: var(--theme-color-button-primary-text);
|
||||||
|
--secondary: var(--color-bg-secondary);
|
||||||
|
--secondary-foreground: var(--color-text-primary);
|
||||||
|
--muted: var(--color-bg-tertiary);
|
||||||
|
--muted-foreground: var(--color-text-tertiary);
|
||||||
|
--accent: var(--theme-color-button-primary-background);
|
||||||
|
--accent-foreground: var(--theme-color-button-primary-text);
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: var(--color-text-primary);
|
||||||
|
--border: var(--color-border-primary);
|
||||||
|
--input: var(--color-border-tertiary);
|
||||||
|
--ring: var(--theme-color-ring);
|
||||||
|
--chart-1: var(--color-accent-400);
|
||||||
|
--chart-2: var(--color-accent-500);
|
||||||
|
--chart-3: var(--color-accent-600);
|
||||||
|
--chart-4: var(--color-accent-700);
|
||||||
|
--chart-5: var(--color-accent-800);
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--background: var(--color-bg-background);
|
||||||
|
--foreground: var(--color-text-primary);
|
||||||
|
--card: var(--theme-color-card-background);
|
||||||
|
--card-foreground: var(--color-text-primary);
|
||||||
|
--popover: var(--theme-color-card-background);
|
||||||
|
--popover-foreground: var(--color-text-primary);
|
||||||
|
--primary: var(--theme-color-button-primary-background);
|
||||||
|
--primary-foreground: var(--theme-color-button-primary-text);
|
||||||
|
--secondary: var(--color-bg-secondary);
|
||||||
|
--secondary-foreground: var(--color-text-primary);
|
||||||
|
--muted: var(--color-bg-tertiary);
|
||||||
|
--muted-foreground: var(--color-text-tertiary);
|
||||||
|
--accent: var(--theme-color-button-primary-background);
|
||||||
|
--accent-foreground: var(--theme-color-button-primary-text);
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: var(--color-text-primary);
|
||||||
|
--border: var(--color-border-primary);
|
||||||
|
--input: var(--color-border-tertiary);
|
||||||
|
--ring: var(--theme-color-ring);
|
||||||
|
--chart-1: var(--color-accent-200);
|
||||||
|
--chart-2: var(--color-accent-300);
|
||||||
|
--chart-3: var(--color-accent-400);
|
||||||
|
--chart-4: var(--color-accent-500);
|
||||||
|
--chart-5: var(--color-accent-600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div
|
||||||
|
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
Name
|
Name
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary"></div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary"></div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
<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>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div
|
||||||
|
class="px-3 py-1.5 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
Email
|
Email
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div
|
||||||
|
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
Name
|
Name
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Email</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Email</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { XMarkIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
|
||||||
import type { Component } from 'vue';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/Components/ui/dropdown-menu';
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
icon: Component;
|
|
||||||
label: string;
|
|
||||||
filterName: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
remove: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
defineSlots<{
|
|
||||||
default(): void;
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="inline-flex items-center gap-0.5 rounded-md bg-tertiary dark:bg-secondary border border-border-secondary">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
class="inline-flex items-center gap-1.5 px-2 py-1 text-sm hover:bg-quaternary dark:hover:bg-tertiary rounded-l-md transition-colors whitespace-nowrap">
|
|
||||||
<component :is="icon" class="h-3.5 w-3.5 text-icon-default" />
|
|
||||||
<span class="font-medium text-foreground">{{ filterName }}</span>
|
|
||||||
<span class="text-muted-foreground">is</span>
|
|
||||||
<span class="text-foreground">{{ label }}</span>
|
|
||||||
<ChevronDownIcon class="h-3 w-3 text-muted-foreground" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<slot />
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="px-1.5 py-1 hover:bg-quaternary dark:hover:bg-tertiary h-full rounded-r-md transition-colors group border-l border-border-secondary"
|
|
||||||
@click="$emit('remove')">
|
|
||||||
<XMarkIcon class="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { UserGroupIcon } from '@heroicons/vue/16/solid';
|
|
||||||
import { DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/Components/ui/dropdown-menu';
|
|
||||||
import BaseFilterBadge from './BaseFilterBadge.vue';
|
|
||||||
import type { Client } from '@/packages/api/src';
|
|
||||||
import { NO_CLIENT_ID } from './constants';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
value: string[];
|
|
||||||
clients: Client[];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
remove: [];
|
|
||||||
'update:value': [value: string[]];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const hasNoClient = computed(() => props.value.includes(NO_CLIENT_ID));
|
|
||||||
|
|
||||||
const label = computed(() => {
|
|
||||||
const count = props.value.length;
|
|
||||||
|
|
||||||
if (count === 0) return 'None';
|
|
||||||
if (count === 1) {
|
|
||||||
if (hasNoClient.value) return 'No client';
|
|
||||||
const client = props.clients.find((c) => c.id === props.value[0]);
|
|
||||||
return client?.name ?? 'Client';
|
|
||||||
}
|
|
||||||
return `${count} selected`;
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggleClient(clientId: string) {
|
|
||||||
const clientIds = props.value.includes(clientId)
|
|
||||||
? props.value.filter((id) => id !== clientId)
|
|
||||||
: [...props.value, clientId];
|
|
||||||
|
|
||||||
emit('update:value', clientIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleNoClient() {
|
|
||||||
const clientIds = hasNoClient.value
|
|
||||||
? props.value.filter((id) => id !== NO_CLIENT_ID)
|
|
||||||
: [...props.value, NO_CLIENT_ID];
|
|
||||||
|
|
||||||
emit('update:value', clientIds);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<BaseFilterBadge
|
|
||||||
:icon="UserGroupIcon"
|
|
||||||
:label="label"
|
|
||||||
filter-name="Client"
|
|
||||||
@remove="emit('remove')">
|
|
||||||
<DropdownMenuCheckboxItem :model-value="hasNoClient" @select.prevent="toggleNoClient">
|
|
||||||
No client
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
v-for="client in clients"
|
|
||||||
:key="client.id"
|
|
||||||
:model-value="value.includes(client.id)"
|
|
||||||
@select.prevent="toggleClient(client.id)">
|
|
||||||
{{ client.name }}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
</BaseFilterBadge>
|
|
||||||
</template>
|
|
||||||
@@ -130,7 +130,7 @@ function updateValue(project: Project) {
|
|||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||||
placeholder="Search for a project..."
|
placeholder="Search for a project..."
|
||||||
@keydown.enter="addProjectIfNoneExists" />
|
@keydown.enter="addProjectIfNoneExists" />
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { CircleStackIcon } from '@heroicons/vue/16/solid';
|
|
||||||
import { DropdownMenuItem } from '@/Components/ui/dropdown-menu';
|
|
||||||
import BaseFilterBadge from './BaseFilterBadge.vue';
|
|
||||||
|
|
||||||
type StatusValue = 'active' | 'archived' | 'all';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
value: StatusValue;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
remove: [];
|
|
||||||
'update:value': [value: StatusValue];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
{ id: 'active' as const, name: 'Active' },
|
|
||||||
{ id: 'archived' as const, name: 'Archived' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const label = computed(() => {
|
|
||||||
return statusOptions.find((opt) => opt.id === props.value)?.name ?? 'Status';
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateStatus(status: StatusValue) {
|
|
||||||
emit('update:value', status);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<BaseFilterBadge
|
|
||||||
:icon="CircleStackIcon"
|
|
||||||
:label="label"
|
|
||||||
filter-name="Status"
|
|
||||||
@remove="emit('remove')">
|
|
||||||
<DropdownMenuItem
|
|
||||||
v-for="option in statusOptions"
|
|
||||||
:key="option.id"
|
|
||||||
:class="[value === option.id && 'bg-accent text-accent-foreground']"
|
|
||||||
@click="updateStatus(option.id)">
|
|
||||||
{{ option.name }}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</BaseFilterBadge>
|
|
||||||
</template>
|
|
||||||
@@ -4,10 +4,7 @@ import { FolderPlusIcon } from '@heroicons/vue/24/solid';
|
|||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
import ProjectTableHeading, {
|
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||||
type SortColumn,
|
|
||||||
type SortDirection,
|
|
||||||
} from '@/Components/Common/Project/ProjectTableHeading.vue';
|
|
||||||
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
||||||
@@ -15,96 +12,13 @@ import { useProjectsStore } from '@/utils/useProjects';
|
|||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
|
||||||
import {
|
|
||||||
useVueTable,
|
|
||||||
getCoreRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
type SortingState,
|
|
||||||
} from '@tanstack/vue-table';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
showBillableRate: boolean;
|
showBillableRate: boolean;
|
||||||
sortColumn: SortColumn;
|
|
||||||
sortDirection: SortDirection;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
sort: [column: SortColumn];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { clients } = storeToRefs(useClientsStore());
|
|
||||||
|
|
||||||
// Create a map of client names for sorting
|
|
||||||
const clientNameMap = computed(() => {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
clients.value.forEach((client) => {
|
|
||||||
map.set(client.id, client.name);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert our sort state to TanStack Table format
|
|
||||||
const sorting = computed<SortingState>(() => [
|
|
||||||
{
|
|
||||||
id: props.sortColumn,
|
|
||||||
desc: props.sortDirection === 'desc',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Define column accessors for sorting
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
id: 'name',
|
|
||||||
accessorFn: (row: Project) => row.name.toLowerCase(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'client_name',
|
|
||||||
accessorFn: (row: Project) => {
|
|
||||||
if (!row.client_id) return '';
|
|
||||||
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'spent_time',
|
|
||||||
accessorFn: (row: Project) => row.spent_time ?? 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'billable_rate',
|
|
||||||
accessorFn: (row: Project) => row.billable_rate ?? 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'status',
|
|
||||||
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const table = useVueTable({
|
|
||||||
get data() {
|
|
||||||
return props.projects;
|
|
||||||
},
|
|
||||||
columns,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
state: {
|
|
||||||
get sorting() {
|
|
||||||
return sorting.value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
manualSorting: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedProjects = computed(() => {
|
|
||||||
return table.getRowModel().rows.map((row) => row.original);
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleSort(column: SortColumn) {
|
|
||||||
emit('sort', column);
|
|
||||||
}
|
|
||||||
|
|
||||||
const showCreateProjectModal = ref(false);
|
const showCreateProjectModal = ref(false);
|
||||||
|
|
||||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||||
return await useProjectsStore().createProject(project);
|
return await useProjectsStore().createProject(project);
|
||||||
}
|
}
|
||||||
@@ -112,10 +26,11 @@ async function createProject(project: CreateProjectBody): Promise<Project | unde
|
|||||||
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
|
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
|
||||||
return await useClientsStore().createClient(client);
|
return await useClientsStore().createClient(client);
|
||||||
}
|
}
|
||||||
|
const { clients } = storeToRefs(useClientsStore());
|
||||||
const gridTemplate = computed(() => {
|
const gridTemplate = computed(() => {
|
||||||
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
|
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
|
||||||
});
|
});
|
||||||
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -130,11 +45,8 @@ const gridTemplate = computed(() => {
|
|||||||
<div class="inline-block min-w-full align-middle">
|
<div class="inline-block min-w-full align-middle">
|
||||||
<div data-testid="project_table" class="grid min-w-full" :style="gridTemplate">
|
<div data-testid="project_table" class="grid min-w-full" :style="gridTemplate">
|
||||||
<ProjectTableHeading
|
<ProjectTableHeading
|
||||||
:show-billable-rate="props.showBillableRate"
|
:show-billable-rate="props.showBillableRate"></ProjectTableHeading>
|
||||||
:sort-column="props.sortColumn"
|
<div v-if="projects.length === 0" class="col-span-5 py-24 text-center">
|
||||||
:sort-direction="props.sortDirection"
|
|
||||||
@sort="handleSort"></ProjectTableHeading>
|
|
||||||
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
|
|
||||||
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||||
<h3 class="text-text-primary font-semibold">
|
<h3 class="text-text-primary font-semibold">
|
||||||
{{
|
{{
|
||||||
@@ -157,7 +69,7 @@ const gridTemplate = computed(() => {
|
|||||||
>Create your First Project
|
>Create your First Project
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="project in sortedProjects" :key="project.id">
|
<template v-for="project in projects" :key="project.id">
|
||||||
<ProjectTableRow
|
<ProjectTableRow
|
||||||
:show-billable-rate="props.showBillableRate"
|
:show-billable-rate="props.showBillableRate"
|
||||||
:project="project"></ProjectTableRow>
|
:project="project"></ProjectTableRow>
|
||||||
|
|||||||
@@ -1,89 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
defineProps<{
|
||||||
|
|
||||||
export type SortColumn = 'name' | 'client_name' | 'spent_time' | 'billable_rate' | 'status';
|
|
||||||
export type SortDirection = 'asc' | 'desc';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
showBillableRate: boolean;
|
showBillableRate: boolean;
|
||||||
sortColumn: SortColumn;
|
|
||||||
sortDirection: SortDirection;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
sort: [column: SortColumn];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
function handleSort(column: SortColumn) {
|
|
||||||
emit('sort', column);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSorted(column: SortColumn): boolean {
|
|
||||||
return props.sortColumn === column;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div
|
<div
|
||||||
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
@click="handleSort('name')">
|
|
||||||
Name
|
Name
|
||||||
<ChevronDownIcon v-if="isSorted('name') && sortDirection === 'asc'" class="w-4 h-4" />
|
|
||||||
<ChevronUpIcon
|
|
||||||
v-else-if="isSorted('name') && sortDirection === 'desc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Client</div>
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
|
||||||
@click="handleSort('client_name')">
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
|
||||||
Client
|
<div v-if="showBillableRate" class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
||||||
<ChevronDownIcon
|
|
||||||
v-if="isSorted('client_name') && sortDirection === 'asc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<ChevronUpIcon
|
|
||||||
v-else-if="isSorted('client_name') && sortDirection === 'desc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('spent_time')">
|
|
||||||
Total Time
|
|
||||||
<ChevronDownIcon
|
|
||||||
v-if="isSorted('spent_time') && sortDirection === 'asc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<ChevronUpIcon
|
|
||||||
v-else-if="isSorted('spent_time') && sortDirection === 'desc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
|
|
||||||
<div
|
|
||||||
v-if="showBillableRate"
|
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('billable_rate')">
|
|
||||||
Billable Rate
|
Billable Rate
|
||||||
<ChevronDownIcon
|
|
||||||
v-if="isSorted('billable_rate') && sortDirection === 'asc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<ChevronUpIcon
|
|
||||||
v-else-if="isSorted('billable_rate') && sortDirection === 'desc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('status')">
|
|
||||||
Status
|
|
||||||
<ChevronDownIcon v-if="isSorted('status') && sortDirection === 'asc'" class="w-4 h-4" />
|
|
||||||
<ChevronUpIcon
|
|
||||||
v-else-if="isSorted('status') && sortDirection === 'desc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
<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>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
|
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
|
||||||
import type { Project } from '@/packages/api/src';
|
import type { Project } from '@/packages/api/src';
|
||||||
import { computed, ref, inject, type ComputedRef } from 'vue';
|
import { computed, ref, inject, type ComputedRef } from 'vue';
|
||||||
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';
|
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useTasksStore } from '@/utils/useTasks';
|
import { useTasksStore } from '@/utils/useTasks';
|
||||||
@@ -116,15 +116,9 @@ const showEditProjectModal = ref(false);
|
|||||||
{{ billableRateInfo }}
|
{{ billableRateInfo }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
|
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
|
||||||
<template v-if="project.is_archived">
|
<CheckCircleIcon class="w-5"></CheckCircleIcon>
|
||||||
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
|
<span>Active</span>
|
||||||
<span>Archived</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
|
|
||||||
<span>Active</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';
|
|
||||||
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from '@/Components/ui/dropdown-menu';
|
|
||||||
import { Button } from '@/packages/ui/src';
|
|
||||||
import type { Client } from '@/packages/api/src';
|
|
||||||
import { NO_CLIENT_ID } from './constants';
|
|
||||||
|
|
||||||
export interface ProjectFilters {
|
|
||||||
status: 'active' | 'archived' | 'all';
|
|
||||||
clientIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
filters: ProjectFilters;
|
|
||||||
clients: Client[];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:filters': [filters: ProjectFilters];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
{ id: 'active' as const, name: 'Active' },
|
|
||||||
{ id: 'archived' as const, name: 'Archived' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const open = ref(false);
|
|
||||||
|
|
||||||
function updateStatus(status: 'active' | 'archived' | 'all') {
|
|
||||||
emit('update:filters', {
|
|
||||||
...props.filters,
|
|
||||||
status,
|
|
||||||
});
|
|
||||||
open.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleClient(clientId: string) {
|
|
||||||
const clientIds = props.filters.clientIds.includes(clientId)
|
|
||||||
? props.filters.clientIds.filter((id) => id !== clientId)
|
|
||||||
: [...props.filters.clientIds, clientId];
|
|
||||||
|
|
||||||
emit('update:filters', {
|
|
||||||
...props.filters,
|
|
||||||
clientIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleNoClient() {
|
|
||||||
const clientIds = props.filters.clientIds.includes(NO_CLIENT_ID)
|
|
||||||
? props.filters.clientIds.filter((id) => id !== NO_CLIENT_ID)
|
|
||||||
: [...props.filters.clientIds, NO_CLIENT_ID];
|
|
||||||
|
|
||||||
emit('update:filters', {
|
|
||||||
...props.filters,
|
|
||||||
clientIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasActiveFilters = computed(() => {
|
|
||||||
return props.filters.status !== 'all' || props.filters.clientIds.length > 0;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DropdownMenu v-model:open="open">
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<Button variant="ghost" size="xs" aria-label="Filter projects">
|
|
||||||
<ListFilterIcon
|
|
||||||
:class="[hasActiveFilters ? '' : '-ml-0.5', 'h-4 w-4 text-icon-default']" />
|
|
||||||
<span v-if="!hasActiveFilters" class="text-nowrap">Filter</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" class="w-56">
|
|
||||||
<!-- Status Filter -->
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger class="gap-2">
|
|
||||||
<CheckCircleIcon class="h-4 w-4 text-icon-default" />
|
|
||||||
<span>Status</span>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent>
|
|
||||||
<DropdownMenuItem
|
|
||||||
v-for="option in statusOptions"
|
|
||||||
:key="option.id"
|
|
||||||
:class="[
|
|
||||||
filters.status === option.id && 'bg-accent text-accent-foreground',
|
|
||||||
]"
|
|
||||||
@click="updateStatus(option.id)">
|
|
||||||
{{ option.name }}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
|
|
||||||
<!-- Client Filter -->
|
|
||||||
<DropdownMenuSub v-if="clients.length > 0">
|
|
||||||
<DropdownMenuSubTrigger class="gap-2">
|
|
||||||
<UserGroupIcon class="h-4 w-4 text-icon-default" />
|
|
||||||
<span>Client</span>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent class="max-h-[300px] overflow-y-auto">
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
:model-value="filters.clientIds.includes(NO_CLIENT_ID)"
|
|
||||||
@select.prevent="toggleNoClient">
|
|
||||||
No client
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
v-for="client in clients"
|
|
||||||
:key="client.id"
|
|
||||||
:model-value="filters.clientIds.includes(client.id)"
|
|
||||||
@select.prevent="toggleClient(client.id)">
|
|
||||||
{{ client.name }}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</template>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const NO_CLIENT_ID = '__no_client__';
|
|
||||||
@@ -4,11 +4,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div
|
||||||
|
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
Name
|
Name
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
<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>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div
|
||||||
|
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
Name
|
Name
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Description</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Description</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Visibility</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Visibility</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Public URL</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Public URL</div>
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
<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>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button } from '@/packages/ui/src';
|
import { Button } from '@/Components/ui/button';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
icon: Component;
|
icon: Component;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Switch } from '@/Components/ui/switch';
|
import { Switch } from '@/Components/ui/switch';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||||
import { Button } from '@/packages/ui/src';
|
import { Button } from '@/Components/ui/button';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="contents [&>*]:border-row-separator text-xs [&>*]:border-b [&>*]: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>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div
|
||||||
|
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
Name
|
Name
|
||||||
</div>
|
</div>
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div
|
||||||
|
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
Task Name
|
Task Name
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Total Time</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
|
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
<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>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
|
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||||
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||||
|
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||||
import { useForm, usePage } from '@inertiajs/vue3';
|
import { useForm, usePage } from '@inertiajs/vue3';
|
||||||
import type { User } from '@/types/models';
|
import type { User } from '@/types/models';
|
||||||
import TimezoneMismatchModal from '@/packages/ui/src/TimezoneMismatchModal.vue';
|
import { useSessionStorage } from '@vueuse/core';
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
const saving = ref(false);
|
const saving = defineModel('saving', { default: false });
|
||||||
|
|
||||||
|
const timezone = ref('');
|
||||||
|
const userTimezone = ref('');
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
auth: {
|
auth: {
|
||||||
@@ -13,11 +21,27 @@ const page = usePage<{
|
|||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function handleUpdate(timezone: string) {
|
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
userTimezone.value = getUserTimezone();
|
||||||
|
|
||||||
|
const now = getDayJsInstance()();
|
||||||
|
|
||||||
|
if (
|
||||||
|
now.tz(timezone.value).format() !== now.tz(userTimezone.value).format() &&
|
||||||
|
!hideTimezoneMismatchModal.value
|
||||||
|
) {
|
||||||
|
show.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit() {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
_method: 'PUT',
|
_method: 'PUT',
|
||||||
timezone: timezone,
|
timezone: timezone.value,
|
||||||
name: page.props.auth.user.name,
|
name: page.props.auth.user.name,
|
||||||
email: page.props.auth.user.email,
|
email: page.props.auth.user.email,
|
||||||
week_start: page.props.auth.user.week_start,
|
week_start: page.props.auth.user.week_start,
|
||||||
@@ -31,15 +55,53 @@ function handleUpdate(timezone: string) {
|
|||||||
show.value = false;
|
show.value = false;
|
||||||
location.reload();
|
location.reload();
|
||||||
},
|
},
|
||||||
onError: () => {
|
|
||||||
saving.value = false;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
show.value = false;
|
||||||
|
hideTimezoneMismatchModal.value = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TimezoneMismatchModal v-model:show="show" :saving="saving" @update="handleUpdate" />
|
<DialogModal closeable :show="show" @close="show = false">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span> Timezone mismatch detected </span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
|
||||||
|
<p>
|
||||||
|
The timezone of your device does not match the timezone in your user
|
||||||
|
settings. <br />
|
||||||
|
<strong
|
||||||
|
>We highly recommend that you update your timezone settings to your
|
||||||
|
current timezone.</strong
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Want to change your timezone setting from
|
||||||
|
<strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton @click="cancel"> Cancel</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
class="ms-3"
|
||||||
|
:class="{ 'opacity-25': saving }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="submit()">
|
||||||
|
Update timezone
|
||||||
|
</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col">
|
<section class="flex flex-col">
|
||||||
<CardTitle :title="title" :icon="icon"></CardTitle>
|
<CardTitle :title="title" :icon="icon"></CardTitle>
|
||||||
<div class="rounded-lg border border-card-border flex-1 flex items-stretch">
|
<div
|
||||||
|
class="rounded-lg bg-card-background border border-card-border flex-1 flex items-stretch shadow-card">
|
||||||
<div class="w-full flex flex-col">
|
<div class="w-full flex flex-col">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ defineProps<{
|
|||||||
<div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator">
|
<div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator">
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<p
|
<p class="font-semibold text-sm text-text-primary">
|
||||||
class="font-semibold text-sm min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
|
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</p>
|
</p>
|
||||||
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
||||||
|
|||||||
@@ -16,25 +16,12 @@ import { useProjectsStore } from '@/utils/useProjects';
|
|||||||
import { useTasksStore } from '@/utils/useTasks';
|
import { useTasksStore } from '@/utils/useTasks';
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
|
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
|
||||||
import type {
|
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
|
||||||
CreateClientBody,
|
|
||||||
CreateProjectBody,
|
|
||||||
CreateTimeEntryBody,
|
|
||||||
Project,
|
|
||||||
Tag,
|
|
||||||
} from '@/packages/api/src';
|
|
||||||
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
|
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
|
||||||
import TimeTrackerMoreOptionsDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
|
|
||||||
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
|
||||||
import { api } from '@/packages/api/src';
|
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
auth: {
|
auth: {
|
||||||
@@ -60,8 +47,6 @@ const emit = defineEmits<{
|
|||||||
change: [];
|
change: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showManualTimeEntryModal = ref(false);
|
|
||||||
|
|
||||||
watch(isActive, () => {
|
watch(isActive, () => {
|
||||||
if (isActive.value) {
|
if (isActive.value) {
|
||||||
startLiveTimer();
|
startLiveTimer();
|
||||||
@@ -108,73 +93,14 @@ function switchToTimeEntryOrganization() {
|
|||||||
switchOrganization(currentTimeEntry.value.organization_id);
|
switchOrganization(currentTimeEntry.value.organization_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function createTag(tag: string): Promise<Tag | undefined> {
|
async function createTag(tag: string) {
|
||||||
return await useTagsStore().createTag(tag);
|
return await useTagsStore().createTag(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
|
|
||||||
await useTimeEntriesStore().createTimeEntry(timeEntry);
|
|
||||||
showManualTimeEntryModal.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTimeEntryFromCurrentEntry() {
|
|
||||||
const { start, end, description, project_id, task_id, billable, tags } = currentTimeEntry.value;
|
|
||||||
await createTimeEntry({ start, end, description, project_id, task_id, billable, tags });
|
|
||||||
currentTimeEntryStore.$reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const deleteTimeEntryMutation = useMutation({
|
|
||||||
mutationFn: async (timeEntryId: string) => {
|
|
||||||
const organizationId = getCurrentOrganizationId();
|
|
||||||
if (!organizationId) {
|
|
||||||
throw new Error('No organization selected');
|
|
||||||
}
|
|
||||||
return await api.deleteTimeEntry(undefined, {
|
|
||||||
params: {
|
|
||||||
organization: organizationId,
|
|
||||||
timeEntry: timeEntryId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: async () => {
|
|
||||||
await currentTimeEntryStore.fetchCurrentTimeEntry();
|
|
||||||
await useTimeEntriesStore().fetchTimeEntries();
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function discardCurrentTimeEntry() {
|
|
||||||
if (currentTimeEntry.value.id) {
|
|
||||||
await handleApiRequestNotifications(
|
|
||||||
() => deleteTimeEntryMutation.mutateAsync(currentTimeEntry.value.id),
|
|
||||||
'Time entry discarded successfully',
|
|
||||||
'Failed to discard time entry'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { tags } = storeToRefs(useTagsStore());
|
const { tags } = storeToRefs(useTagsStore());
|
||||||
const { timeEntries } = storeToRefs(useTimeEntriesStore());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TimeEntryCreateModal
|
|
||||||
v-model:show="showManualTimeEntryModal"
|
|
||||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
|
||||||
:create-project="createProject"
|
|
||||||
:create-client="createClient"
|
|
||||||
:create-tag="createTag"
|
|
||||||
:create-time-entry="createTimeEntry"
|
|
||||||
:currency="getOrganizationCurrencyString()"
|
|
||||||
:can-create-project="canCreateProjects()"
|
|
||||||
:projects
|
|
||||||
:tasks
|
|
||||||
:tags
|
|
||||||
:clients></TimeEntryCreateModal>
|
|
||||||
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
|
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<TimeTrackerRunningInDifferentOrganizationOverlay
|
<TimeTrackerRunningInDifferentOrganizationOverlay
|
||||||
@@ -183,36 +109,24 @@ const { timeEntries } = storeToRefs(useTimeEntriesStore());
|
|||||||
switchToTimeEntryOrganization
|
switchToTimeEntryOrganization
|
||||||
"></TimeTrackerRunningInDifferentOrganizationOverlay>
|
"></TimeTrackerRunningInDifferentOrganizationOverlay>
|
||||||
|
|
||||||
<div class="flex w-full items-center gap-2">
|
<TimeTrackerControls
|
||||||
<div class="flex w-full items-center gap-2">
|
v-model:current-time-entry="currentTimeEntry"
|
||||||
<div class="flex-1">
|
v-model:live-timer="now"
|
||||||
<TimeTrackerControls
|
:create-project
|
||||||
v-model:current-time-entry="currentTimeEntry"
|
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||||
v-model:live-timer="now"
|
:can-create-project="canCreateProjects()"
|
||||||
:create-project
|
:create-client
|
||||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
:clients
|
||||||
:can-create-project="canCreateProjects()"
|
:tags
|
||||||
:create-client
|
:tasks
|
||||||
:clients
|
:projects
|
||||||
:tags
|
:create-tag
|
||||||
:tasks
|
:is-active
|
||||||
:projects
|
:currency="getOrganizationCurrencyString()"
|
||||||
:time-entries
|
@start-live-timer="startLiveTimer"
|
||||||
:create-tag
|
@stop-live-timer="stopLiveTimer"
|
||||||
:is-active
|
@start-timer="setActiveState(true)"
|
||||||
:currency="getOrganizationCurrencyString()"
|
@stop-timer="setActiveState(false)"
|
||||||
@start-live-timer="startLiveTimer"
|
@update-time-entry="updateTimeEntry"></TimeTrackerControls>
|
||||||
@stop-live-timer="stopLiveTimer"
|
|
||||||
@start-timer="setActiveState(true)"
|
|
||||||
@stop-timer="setActiveState(false)"
|
|
||||||
@update-time-entry="updateTimeEntry"
|
|
||||||
@create-time-entry="createTimeEntryFromCurrentEntry"></TimeTrackerControls>
|
|
||||||
</div>
|
|
||||||
<TimeTrackerMoreOptionsDropdown
|
|
||||||
:has-active-timer="isActive"
|
|
||||||
@manual-entry="showManualTimeEntryModal = true"
|
|
||||||
@discard="discardCurrentTimeEntry"></TimeTrackerMoreOptionsDropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { cn } from '../utils/cn';
|
import { cn } from '@/lib/utils';
|
||||||
import { AccordionContent, type AccordionContentProps } from 'reka-ui';
|
import { AccordionContent, type AccordionContentProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { cn } from '../utils/cn';
|
import { cn } from '@/lib/utils';
|
||||||
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'reka-ui';
|
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { cn } from '../utils/cn';
|
import { cn } from '@/lib/utils';
|
||||||
import { ChevronDown } from 'lucide-vue-next';
|
import { ChevronDown } from 'lucide-vue-next';
|
||||||
import { AccordionHeader, AccordionTrigger, type AccordionTriggerProps } from 'reka-ui';
|
import { AccordionHeader, AccordionTrigger, type AccordionTriggerProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { buttonVariants } from '@/packages/ui/src';
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui';
|
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { buttonVariants } from '@/packages/ui/src';
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui';
|
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui';
|
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||||
import { Button } from '@/packages/ui/src';
|
import { Button } from '@/Components/ui/button';
|
||||||
import { Calendar } from '@/Components/ui/calendar';
|
import { Calendar } from '@/Components/ui/calendar';
|
||||||
import { CalendarIcon, XIcon } from 'lucide-vue-next';
|
import { CalendarIcon, XIcon } from 'lucide-vue-next';
|
||||||
import { formatDate } from '@/packages/ui/src/utils/time';
|
import { formatDate } from '@/packages/ui/src/utils/time';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn, buttonVariants } from '@/packages/ui/src/index';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { ChevronRight } from 'lucide-vue-next';
|
import { ChevronRight } from 'lucide-vue-next';
|
||||||
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui';
|
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { ChevronLeft } from 'lucide-vue-next';
|
import { ChevronLeft } from 'lucide-vue-next';
|
||||||
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui';
|
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import {
|
import {
|
||||||
RangeCalendarCellTrigger,
|
RangeCalendarCellTrigger,
|
||||||
type RangeCalendarCellTriggerProps,
|
type RangeCalendarCellTriggerProps,
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { ChevronRight } from 'lucide-vue-next';
|
import { ChevronRight } from 'lucide-vue-next';
|
||||||
import { RangeCalendarNext, type RangeCalendarNextProps, useForwardProps } from 'reka-ui';
|
import { RangeCalendarNext, type RangeCalendarNextProps, useForwardProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { ChevronLeft } from 'lucide-vue-next';
|
import { ChevronLeft } from 'lucide-vue-next';
|
||||||
import { RangeCalendarPrev, type RangeCalendarPrevProps, useForwardProps } from 'reka-ui';
|
import { RangeCalendarPrev, type RangeCalendarPrevProps, useForwardProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
@@ -15,7 +15,9 @@ const delegatedProps = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<TabsList
|
<TabsList
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
:class="cn('inline-flex items-center rounded-lg text-muted-foreground', props.class)">
|
:class="
|
||||||
|
cn('inline-flex items-center rounded-lg bg-muted text-muted-foreground', props.class)
|
||||||
|
">
|
||||||
<slot />
|
<slot />
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import { api } from '@/packages/api/src';
|
|||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import { Button } from '@/packages/ui/src';
|
import Button from '@/Components/ui/button/Button.vue';
|
||||||
import { openFeedback } from '@/utils/feedback';
|
import { openFeedback } from '@/utils/feedback';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type Project,
|
type Project,
|
||||||
type TimeEntryResponse,
|
type TimeEntryResponse,
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||||
import { TimeEntryCalendar } from '@/packages/ui/src';
|
import { TimeEntryCalendar } from '@/packages/ui/src';
|
||||||
@@ -21,8 +21,6 @@ import { useClientsStore } from '@/utils/useClients';
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useTasksStore } from '@/utils/useTasks';
|
import { useTasksStore } from '@/utils/useTasks';
|
||||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
|
||||||
|
|
||||||
const calendarStart = ref<Date | undefined>(undefined);
|
const calendarStart = ref<Date | undefined>(undefined);
|
||||||
const calendarEnd = ref<Date | undefined>(undefined);
|
const calendarEnd = ref<Date | undefined>(undefined);
|
||||||
@@ -75,7 +73,6 @@ const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useQuery<Time
|
|||||||
queries: {
|
queries: {
|
||||||
start: expandedDateRange.value.start!,
|
start: expandedDateRange.value.start!,
|
||||||
end: expandedDateRange.value.end!,
|
end: expandedDateRange.value.end!,
|
||||||
member_id: getCurrentMembershipId(),
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -131,8 +128,6 @@ function onRefresh() {
|
|||||||
:tags="tags"
|
:tags="tags"
|
||||||
:loading="timeEntriesLoading"
|
:loading="timeEntriesLoading"
|
||||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||||
:currency="getOrganizationCurrencyString()"
|
|
||||||
:can-create-project="canCreateProjects()"
|
|
||||||
:create-time-entry="createTimeEntry"
|
:create-time-entry="createTimeEntry"
|
||||||
:update-time-entry="updateTimeEntry"
|
:update-time-entry="updateTimeEntry"
|
||||||
:delete-time-entry="deleteTimeEntry"
|
:delete-time-entry="deleteTimeEntry"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const refreshDashboardData = () => {
|
|||||||
</MainContainer>
|
</MainContainer>
|
||||||
|
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="grid gap-2 sm:gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch">
|
class="grid gap-5 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch">
|
||||||
<RecentlyTrackedTasksCard></RecentlyTrackedTasksCard>
|
<RecentlyTrackedTasksCard></RecentlyTrackedTasksCard>
|
||||||
<LastSevenDaysCard></LastSevenDaysCard>
|
<LastSevenDaysCard></LastSevenDaysCard>
|
||||||
<ActivityGraphCard></ActivityGraphCard>
|
<ActivityGraphCard></ActivityGraphCard>
|
||||||
|
|||||||
@@ -4,16 +4,13 @@ import AppLayout from '@/Layouts/AppLayout.vue';
|
|||||||
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
|
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
|
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
|
||||||
import type {
|
import { computed, onMounted, ref } from 'vue';
|
||||||
SortColumn,
|
|
||||||
SortDirection,
|
|
||||||
} from '@/Components/Common/Project/ProjectTableHeading.vue';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
|
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
||||||
|
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src';
|
import type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src';
|
||||||
@@ -21,95 +18,31 @@ import { getOrganizationCurrencyString } from '@/utils/money';
|
|||||||
import { getCurrentRole } from '@/utils/useUser';
|
import { getCurrentRole } from '@/utils/useUser';
|
||||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { useStorage } from '@vueuse/core';
|
|
||||||
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
|
|
||||||
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
|
|
||||||
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
|
|
||||||
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
|
|
||||||
|
|
||||||
// Fetch data using TanStack Query
|
|
||||||
const { projects } = useProjectsQuery();
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
useProjectsStore().fetchProjects();
|
||||||
|
useOrganizationStore().fetchOrganization();
|
||||||
|
});
|
||||||
const { clients } = storeToRefs(useClientsStore());
|
const { clients } = storeToRefs(useClientsStore());
|
||||||
|
const showCreateProjectModal = ref(false);
|
||||||
|
|
||||||
const { organization } = storeToRefs(useOrganizationStore());
|
const { organization } = storeToRefs(useOrganizationStore());
|
||||||
|
|
||||||
// Table state persisted in localStorage
|
const activeTab = ref<'active' | 'archived'>('active');
|
||||||
interface ProjectTableState {
|
|
||||||
sortColumn: SortColumn;
|
|
||||||
sortDirection: SortDirection;
|
|
||||||
filters: {
|
|
||||||
clientIds: string[];
|
|
||||||
status: 'active' | 'archived' | 'all';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableState = useStorage<ProjectTableState>(
|
const { projects } = storeToRefs(useProjectsStore());
|
||||||
'project-table-state',
|
|
||||||
{
|
|
||||||
sortColumn: 'name',
|
|
||||||
sortDirection: 'asc',
|
|
||||||
filters: {
|
|
||||||
clientIds: [],
|
|
||||||
status: 'all',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ mergeDefaults: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle sorting - toggle direction if same column, otherwise set new column with asc
|
const shownProjects = computed(() => {
|
||||||
function handleSort(column: SortColumn) {
|
|
||||||
if (tableState.value.sortColumn === column) {
|
|
||||||
tableState.value.sortDirection = tableState.value.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
||||||
} else {
|
|
||||||
tableState.value.sortColumn = column;
|
|
||||||
tableState.value.sortDirection = 'asc';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter projects based on current filters
|
|
||||||
const filteredProjects = computed(() => {
|
|
||||||
return projects.value.filter((project) => {
|
return projects.value.filter((project) => {
|
||||||
// Status filter
|
if (activeTab.value === 'active') {
|
||||||
if (tableState.value.filters.status === 'active' && project.is_archived) {
|
return !project.is_archived;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
if (tableState.value.filters.status === 'archived' && !project.is_archived) {
|
return project.is_archived;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client filter
|
|
||||||
const hasClientFilter = tableState.value.filters.clientIds.length > 0;
|
|
||||||
if (hasClientFilter) {
|
|
||||||
const matchesNoClient =
|
|
||||||
tableState.value.filters.clientIds.includes(NO_CLIENT_ID) && !project.client_id;
|
|
||||||
const matchesClientId =
|
|
||||||
project.client_id && tableState.value.filters.clientIds.includes(project.client_id);
|
|
||||||
|
|
||||||
if (!matchesNoClient && !matchesClientId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper functions for active filters
|
|
||||||
function removeStatusFilter() {
|
|
||||||
tableState.value.filters.status = 'all';
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeClientFilter() {
|
|
||||||
tableState.value.filters.clientIds = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const showCreateProjectModal = useStorage('project-create-modal-open', false);
|
|
||||||
|
|
||||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||||
return await useProjectsStore().createProject(project);
|
return await useProjectsStore().createProject(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
|
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
|
||||||
return await useClientsStore().createClient(client);
|
return await useClientsStore().createClient(client);
|
||||||
}
|
}
|
||||||
@@ -124,9 +57,13 @@ const showBillableRate = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout title="Projects" data-testid="projects_view">
|
<AppLayout title="Projects" data-testid="projects_view">
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="py-3 sm:pt-5 border-b border-default-background-separator flex justify-between items-center">
|
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
|
||||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||||
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
|
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
|
||||||
|
<TabBar v-model="activeTab">
|
||||||
|
<TabBarItem value="active">Active</TabBarItem>
|
||||||
|
<TabBarItem value="archived">Archived</TabBarItem>
|
||||||
|
</TabBar>
|
||||||
</div>
|
</div>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
v-if="canCreateProjects()"
|
v-if="canCreateProjects()"
|
||||||
@@ -143,38 +80,8 @@ const showBillableRate = computed(() => {
|
|||||||
:clients="clients"
|
:clients="clients"
|
||||||
@submit="createProject"></ProjectCreateModal>
|
@submit="createProject"></ProjectCreateModal>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<MainContainer>
|
|
||||||
<div class="flex items-center gap-2 py-1">
|
|
||||||
<ProjectsFilterDropdown
|
|
||||||
:filters="tableState.filters"
|
|
||||||
:clients="clients"
|
|
||||||
@update:filters="tableState.filters = $event" />
|
|
||||||
|
|
||||||
<!-- Active Filters -->
|
|
||||||
<ProjectStatusFilterBadge
|
|
||||||
v-if="tableState.filters.status !== 'all'"
|
|
||||||
data-testid="status-filter-badge"
|
|
||||||
:value="tableState.filters.status"
|
|
||||||
@remove="removeStatusFilter"
|
|
||||||
@update:value="
|
|
||||||
tableState.filters.status = $event as 'active' | 'archived' | 'all'
|
|
||||||
" />
|
|
||||||
|
|
||||||
<ProjectClientFilterBadge
|
|
||||||
v-if="tableState.filters.clientIds.length > 0"
|
|
||||||
data-testid="client-filter-badge"
|
|
||||||
:value="tableState.filters.clientIds"
|
|
||||||
:clients="clients"
|
|
||||||
@remove="removeClientFilter"
|
|
||||||
@update:value="tableState.filters.clientIds = $event as string[]" />
|
|
||||||
</div>
|
|
||||||
</MainContainer>
|
|
||||||
|
|
||||||
<ProjectTable
|
<ProjectTable
|
||||||
:show-billable-rate="showBillableRate"
|
:show-billable-rate="showBillableRate"
|
||||||
:projects="filteredProjects"
|
:projects="shownProjects"></ProjectTable>
|
||||||
:sort-column="tableState.sortColumn"
|
|
||||||
:sort-direction="tableState.sortDirection"
|
|
||||||
@sort="handleSort"></ProjectTable>
|
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
@submit="clearSelectionAndState"
|
@submit="clearSelectionAndState"
|
||||||
@select-all="selectedTimeEntries = [...timeEntries]"
|
@select-all="selectedTimeEntries = [...timeEntries]"
|
||||||
@unselect-all="selectedTimeEntries = []"></TimeEntryMassActionRow>
|
@unselect-all="selectedTimeEntries = []"></TimeEntryMassActionRow>
|
||||||
<div class="w-full relative @container">
|
<div class="w-full relative">
|
||||||
<div v-for="entry in timeEntries" :key="entry.id">
|
<div v-for="entry in timeEntries" :key="entry.id">
|
||||||
<TimeEntryRow
|
<TimeEntryRow
|
||||||
:selected="selectedTimeEntries.includes(entry)"
|
:selected="selectedTimeEntries.includes(entry)"
|
||||||
|
|||||||
@@ -14,18 +14,13 @@ const { updateOrganization } = store;
|
|||||||
const { organization } = storeToRefs(store);
|
const { organization } = storeToRefs(store);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const form = ref<{
|
const form = ref<{ prevent_overlapping_time_entries: boolean }>({
|
||||||
prevent_overlapping_time_entries: boolean;
|
|
||||||
employees_can_manage_tasks: boolean;
|
|
||||||
}>({
|
|
||||||
prevent_overlapping_time_entries: false,
|
prevent_overlapping_time_entries: false,
|
||||||
employees_can_manage_tasks: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
form.value.prevent_overlapping_time_entries =
|
form.value.prevent_overlapping_time_entries =
|
||||||
organization.value?.prevent_overlapping_time_entries ?? false;
|
organization.value?.prevent_overlapping_time_entries ?? false;
|
||||||
form.value.employees_can_manage_tasks = organization.value?.employees_can_manage_tasks ?? false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
@@ -38,22 +33,22 @@ const mutation = useMutation({
|
|||||||
async function submit() {
|
async function submit() {
|
||||||
await mutation.mutateAsync({
|
await mutation.mutateAsync({
|
||||||
prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries,
|
prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries,
|
||||||
employees_can_manage_tasks: form.value.employees_can_manage_tasks,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FormSection>
|
<FormSection>
|
||||||
<template #title>Organization Settings</template>
|
<template #title>Time Entry Settings</template>
|
||||||
<template #description>
|
<template #description>
|
||||||
Configure various settings for your organization, including time entry and task
|
Disallow overlapping time entries for members of this organization. When enabled, users
|
||||||
management permissions.
|
cannot create new time entries that overlap with their existing ones. This only affects
|
||||||
|
newly created entries.
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #form>
|
<template #form>
|
||||||
<div class="col-span-6">
|
<div class="col-span-6">
|
||||||
<div class="col-span-6 sm:col-span-4 space-y-4">
|
<div class="col-span-6 sm:col-span-4">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="preventOverlappingTimeEntries"
|
id="preventOverlappingTimeEntries"
|
||||||
@@ -62,14 +57,6 @@ async function submit() {
|
|||||||
for="preventOverlappingTimeEntries"
|
for="preventOverlappingTimeEntries"
|
||||||
value="Prevent overlapping time entries (new entries only)" />
|
value="Prevent overlapping time entries (new entries only)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="employeesCanManageTasks"
|
|
||||||
v-model:checked="form.employees_can_manage_tasks" />
|
|
||||||
<InputLabel
|
|
||||||
for="employeesCanManageTasks"
|
|
||||||
value="Allow Employees to manage tasks" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { useElementVisibility } from '@vueuse/core';
|
import { useElementVisibility } from '@vueuse/core';
|
||||||
import { ClockIcon } from '@heroicons/vue/20/solid';
|
import { ClockIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
import { useTasksStore } from '@/utils/useTasks';
|
import { useTasksStore } from '@/utils/useTasks';
|
||||||
@@ -22,6 +24,7 @@ import { useProjectsStore } from '@/utils/useProjects';
|
|||||||
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
|
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||||
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
|
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
|
||||||
@@ -70,6 +73,7 @@ onMounted(async () => {
|
|||||||
await timeEntriesStore.fetchTimeEntries();
|
await timeEntriesStore.fetchTimeEntries();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showManualTimeEntryModal = ref(false);
|
||||||
const projectStore = useProjectsStore();
|
const projectStore = useProjectsStore();
|
||||||
const { projects } = storeToRefs(projectStore);
|
const { projects } = storeToRefs(projectStore);
|
||||||
const taskStore = useTasksStore();
|
const taskStore = useTasksStore();
|
||||||
@@ -101,9 +105,33 @@ function deleteSelected() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<TimeEntryCreateModal
|
||||||
|
v-model:show="showManualTimeEntryModal"
|
||||||
|
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||||
|
:create-project="createProject"
|
||||||
|
:create-client="createClient"
|
||||||
|
:create-tag="createTag"
|
||||||
|
:create-time-entry="createTimeEntry"
|
||||||
|
:projects
|
||||||
|
:tasks
|
||||||
|
:tags
|
||||||
|
:clients></TimeEntryCreateModal>
|
||||||
<AppLayout title="Dashboard" data-testid="time_view">
|
<AppLayout title="Dashboard" data-testid="time_view">
|
||||||
<MainContainer class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
<MainContainer class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
||||||
<TimeTracker></TimeTracker>
|
<div
|
||||||
|
class="lg:flex items-end lg:divide-x divide-default-background-separator divide-y lg:divide-y-0 space-y-2 lg:space-y-0 lg:space-x-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<TimeTracker></TimeTracker>
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pt-2 lg:pt-0 lg:pl-4 flex justify-center">
|
||||||
|
<SecondaryButton
|
||||||
|
class="w-full text-center flex justify-center"
|
||||||
|
:icon="PlusIcon"
|
||||||
|
@click="showManualTimeEntryModal = true"
|
||||||
|
>Manual time entry
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<TimeEntryMassActionRow
|
<TimeEntryMassActionRow
|
||||||
:selected-time-entries="selectedTimeEntries"
|
:selected-time-entries="selectedTimeEntries"
|
||||||
|
|||||||
193
resources/js/packages/api/package-lock.json
generated
193
resources/js/packages/api/package-lock.json
generated
@@ -1,16 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "@solidtime/api",
|
"name": "@solidtime/api",
|
||||||
"version": "0.0.6",
|
"version": "0.0.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@solidtime/api",
|
"name": "@solidtime/api",
|
||||||
"version": "0.0.6",
|
"version": "0.0.3",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"axios": "^1.13.2",
|
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
@@ -1095,16 +1094,18 @@
|
|||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.2",
|
"version": "1.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz",
|
||||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
"integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
"form-data": "^4.0.4",
|
"form-data": "^4.0.0",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1126,24 +1127,12 @@
|
|||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/call-bind-apply-helpers": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"delayed-stream": "~1.0.0"
|
"delayed-stream": "~1.0.0"
|
||||||
},
|
},
|
||||||
@@ -1209,24 +1198,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dunder-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"gopd": "^1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
@@ -1240,51 +1216,6 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-define-property": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-errors": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-object-atoms": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-set-tostringtag": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.6",
|
|
||||||
"has-tostringtag": "^1.0.2",
|
|
||||||
"hasown": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@@ -1349,6 +1280,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
},
|
},
|
||||||
@@ -1359,15 +1291,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
|
||||||
"hasown": "^2.0.2",
|
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.12"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1408,60 +1339,12 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-intrinsic": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
|
||||||
"es-define-property": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"es-object-atoms": "^1.1.1",
|
|
||||||
"function-bind": "^1.1.2",
|
|
||||||
"get-proto": "^1.0.1",
|
|
||||||
"gopd": "^1.2.0",
|
|
||||||
"has-symbols": "^1.1.0",
|
|
||||||
"hasown": "^2.0.2",
|
|
||||||
"math-intrinsics": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dunder-proto": "^1.0.1",
|
|
||||||
"es-object-atoms": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/gopd": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
@@ -1479,37 +1362,11 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/has-symbols": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/has-tostringtag": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"has-symbols": "^1.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -1632,20 +1489,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/math-intrinsics": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -1655,6 +1504,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "1.52.0"
|
"mime-db": "1.52.0"
|
||||||
},
|
},
|
||||||
@@ -1807,7 +1657,8 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@solidtime/api",
|
"name": "@solidtime/api",
|
||||||
"version": "0.0.6",
|
"version": "0.0.4",
|
||||||
"description": "Package containing the solidtime api client and type declarations",
|
"description": "Package containing the solidtime api client and type declarations",
|
||||||
"main": "./dist/solidtime-api.umd.cjs",
|
"main": "./dist/solidtime-api.umd.cjs",
|
||||||
"module": "./dist/solidtime-api.js",
|
"module": "./dist/solidtime-api.js",
|
||||||
@@ -29,7 +29,6 @@
|
|||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"axios": "^1.13.2",
|
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -317,7 +317,6 @@ const OrganizationResource = z
|
|||||||
is_personal: z.boolean(),
|
is_personal: z.boolean(),
|
||||||
billable_rate: z.union([z.number(), z.null()]),
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
employees_can_see_billable_rates: z.boolean(),
|
employees_can_see_billable_rates: z.boolean(),
|
||||||
employees_can_manage_tasks: z.boolean(),
|
|
||||||
prevent_overlapping_time_entries: z.boolean(),
|
prevent_overlapping_time_entries: z.boolean(),
|
||||||
currency: z.string(),
|
currency: z.string(),
|
||||||
currency_symbol: z.string(),
|
currency_symbol: z.string(),
|
||||||
@@ -333,7 +332,6 @@ const OrganizationUpdateRequest = z
|
|||||||
name: z.string().max(255),
|
name: z.string().max(255),
|
||||||
billable_rate: z.union([z.number(), z.null()]),
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
employees_can_see_billable_rates: z.boolean(),
|
employees_can_see_billable_rates: z.boolean(),
|
||||||
employees_can_manage_tasks: z.boolean(),
|
|
||||||
prevent_overlapping_time_entries: z.boolean(),
|
prevent_overlapping_time_entries: z.boolean(),
|
||||||
number_format: NumberFormat,
|
number_format: NumberFormat,
|
||||||
currency_format: CurrencyFormat,
|
currency_format: CurrencyFormat,
|
||||||
|
|||||||
2014
resources/js/packages/ui/package-lock.json
generated
2014
resources/js/packages/ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@solidtime/ui",
|
"name": "@solidtime/ui",
|
||||||
"version": "0.0.15",
|
"version": "0.0.10",
|
||||||
"description": "Package containing the solidtime ui components",
|
"description": "Package containing the solidtime ui components",
|
||||||
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
||||||
"module": "./dist/solidtime-ui-lib.js",
|
"module": "./dist/solidtime-ui-lib.js",
|
||||||
@@ -21,21 +21,16 @@
|
|||||||
"default": "./dist/solidtime-ui-lib.umd.cjs"
|
"default": "./dist/solidtime-ui-lib.umd.cjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"./style.css": "./dist/style.css",
|
"./style.css": "./dist/style.css"
|
||||||
"./styles.css": "./styles.css",
|
|
||||||
"./tailwind.theme.js": "./tailwind.theme.js"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build && vue-tsc --emitDeclarationOnly",
|
"build": "vite build && vue-tsc --emitDeclarationOnly",
|
||||||
"watch": "vite build --watch",
|
|
||||||
"types": "vue-tsc ",
|
"types": "vue-tsc ",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist"
|
||||||
"styles.css",
|
|
||||||
"tailwind.theme.js"
|
|
||||||
],
|
],
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"solidtime",
|
"solidtime",
|
||||||
@@ -64,11 +59,8 @@
|
|||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@vueuse/core": "^12.5.0",
|
"@vueuse/core": "^12.5.0",
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"parse-duration": "^2.0.1",
|
"parse-duration": "^2.0.1",
|
||||||
"reka-ui": "^2.2.0",
|
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss": "^3.1.0",
|
"tailwindcss": "^3.1.0",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { HTMLAttributes } from 'vue';
|
|
||||||
import { cn } from '../utils/cn';
|
|
||||||
import { Primitive, type PrimitiveProps } from 'reka-ui';
|
|
||||||
import { type ButtonVariants, buttonVariants } from '.';
|
|
||||||
|
|
||||||
interface Props extends PrimitiveProps {
|
|
||||||
variant?: ButtonVariants['variant'];
|
|
||||||
size?: ButtonVariants['size'];
|
|
||||||
class?: HTMLAttributes['class'];
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
as: 'button',
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Primitive
|
|
||||||
:as="as"
|
|
||||||
:as-child="asChild"
|
|
||||||
:class="cn(buttonVariants({ variant, size }), props.class)">
|
|
||||||
<slot />
|
|
||||||
</Primitive>
|
|
||||||
</template>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
|
||||||
|
|
||||||
export { default as Button } from './Button.vue';
|
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
|
||||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
|
||||||
destructive:
|
|
||||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
|
||||||
outline:
|
|
||||||
'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5',
|
|
||||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
|
||||||
ghost: 'hover:bg-white/5',
|
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
|
||||||
input: 'border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent shadow-sm',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: 'h-9 px-4 py-2',
|
|
||||||
xs: 'h-7 rounded px-2',
|
|
||||||
sm: 'h-8 rounded-md px-3 text-xs',
|
|
||||||
lg: 'h-10 rounded-md px-8',
|
|
||||||
icon: 'h-9 w-9',
|
|
||||||
input: 'h-[42px] px-3 py-2 text-base',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default',
|
|
||||||
size: 'default',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
|
||||||
@@ -92,7 +92,7 @@ function updateValue(client: { id: string | null; name: string }) {
|
|||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||||
placeholder="Search for a client..." />
|
placeholder="Search for a client..." />
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
<ComboboxContent>
|
<ComboboxContent>
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import type { Dayjs } from 'dayjs';
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
date: Dayjs;
|
date: Dayjs;
|
||||||
totalSeconds?: number;
|
totalMinutes?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
|
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
|
||||||
|
|
||||||
// Injected organization for formatting settings
|
// Injected organization for formatting settings
|
||||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
||||||
@@ -23,9 +23,9 @@ const dateFormat = computed(() => organization?.value?.date_format);
|
|||||||
<div class="text-xs text-muted-foreground font-medium">
|
<div class="text-xs text-muted-foreground font-medium">
|
||||||
{{ date.format('ddd') }}
|
{{ date.format('ddd') }}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
||||||
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
||||||
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
|
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -40,18 +40,18 @@ const formattedDuration = computed(() =>
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="text-2xs leading-tight px-0.5 py-1.5">
|
<div class="text-xs leading-tight">
|
||||||
<div class="font-semibold">{{ title }}</div>
|
<div class="font-semibold mb-0.5">{{ title }}</div>
|
||||||
<div v-if="projectName" class="font-medium opacity-90">
|
<div v-if="projectName" class="font-medium text-[0.6875rem] opacity-90">
|
||||||
{{ projectName }}
|
{{ projectName }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="taskName" class="font-medium">
|
<div v-if="taskName" class="font-medium text-[0.6875rem] opacity-90">
|
||||||
{{ taskName }}
|
{{ taskName }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="clientName" class="opacity-85">
|
<div v-if="clientName" class="text-[0.625rem] italic opacity-85">
|
||||||
{{ clientName }}
|
{{ clientName }}
|
||||||
</div>
|
</div>
|
||||||
<div class="opacity-90">
|
<div class="text-[0.625rem] font-semibold opacity-90 mt-0.5">
|
||||||
{{ formattedDuration }}
|
{{ formattedDuration }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,17 +4,7 @@ import dayGridPlugin from '@fullcalendar/daygrid';
|
|||||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
|
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
|
||||||
import {
|
import { computed, ref, watch, inject, type ComputedRef } from 'vue';
|
||||||
computed,
|
|
||||||
ref,
|
|
||||||
watch,
|
|
||||||
inject,
|
|
||||||
type ComputedRef,
|
|
||||||
nextTick,
|
|
||||||
onMounted,
|
|
||||||
onActivated,
|
|
||||||
onUnmounted,
|
|
||||||
} from 'vue';
|
|
||||||
import chroma from 'chroma-js';
|
import chroma from 'chroma-js';
|
||||||
import { useCssVariable } from '@/utils/useCssVariable';
|
import { useCssVariable } from '@/utils/useCssVariable';
|
||||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||||
@@ -22,10 +12,6 @@ import { getUserTimezone, getWeekStart } from '../utils/settings';
|
|||||||
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
||||||
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
||||||
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
||||||
import activityStatusPlugin, {
|
|
||||||
type ActivityPeriod,
|
|
||||||
renderActivityStatusBoxes,
|
|
||||||
} from './idleStatusPlugin';
|
|
||||||
import type {
|
import type {
|
||||||
TimeEntry,
|
TimeEntry,
|
||||||
Project,
|
Project,
|
||||||
@@ -38,10 +24,7 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
type CalendarExtendedProps = { timeEntry: TimeEntry; isRunning?: boolean } & Record<
|
type CalendarExtendedProps = { timeEntry: TimeEntry } & Record<string, unknown>;
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'dates-change', payload: { start: Date; end: Date }): void;
|
(e: 'dates-change', payload: { start: Date; end: Date }): void;
|
||||||
@@ -54,13 +37,10 @@ const props = defineProps<{
|
|||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
activityPeriods?: ActivityPeriod[];
|
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
|
||||||
// Permissions / feature flags
|
// Permissions / feature flags
|
||||||
enableEstimatedTime: boolean;
|
enableEstimatedTime: boolean;
|
||||||
currency: string;
|
|
||||||
canCreateProject: boolean;
|
|
||||||
|
|
||||||
createTimeEntry: (
|
createTimeEntry: (
|
||||||
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
|
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
|
||||||
@@ -81,10 +61,6 @@ const selectedTimeEntry = ref<TimeEntry | null>(null);
|
|||||||
|
|
||||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||||
|
|
||||||
// Reactive "now" for running time entry - updates every minute
|
|
||||||
const currentTime = ref(getDayJsInstance()());
|
|
||||||
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
// Inject organization data for settings
|
// Inject organization data for settings
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
|
||||||
@@ -126,81 +102,67 @@ const events = computed(() => {
|
|||||||
const themeBackground = (() => {
|
const themeBackground = (() => {
|
||||||
return cssBackground.value?.trim();
|
return cssBackground.value?.trim();
|
||||||
})();
|
})();
|
||||||
return props.timeEntries?.map((timeEntry) => {
|
return props.timeEntries
|
||||||
const isRunning = timeEntry.end === null;
|
?.filter((timeEntry) => timeEntry.end !== null)
|
||||||
const project = props.projects.find((p) => p.id === timeEntry.project_id);
|
?.map((timeEntry) => {
|
||||||
const client = props.clients.find((c) => c.id === project?.client_id);
|
const project = props.projects.find((p) => p.id === timeEntry.project_id);
|
||||||
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
|
const client = props.clients.find((c) => c.id === project?.client_id);
|
||||||
|
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
|
||||||
|
const duration = getDayJsInstance()(timeEntry.end!).diff(
|
||||||
|
getDayJsInstance()(timeEntry.start),
|
||||||
|
'minutes'
|
||||||
|
);
|
||||||
|
|
||||||
// For running entries, use current time as end
|
const title = timeEntry.description || 'No description';
|
||||||
const effectiveEnd = isRunning ? currentTime.value : getDayJsInstance()(timeEntry.end!);
|
|
||||||
const duration = effectiveEnd.diff(getDayJsInstance()(timeEntry.start), 'minutes');
|
|
||||||
|
|
||||||
const title = timeEntry.description || 'No description';
|
const baseColor = project?.color || '#6B7280';
|
||||||
|
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
|
||||||
|
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
|
||||||
|
|
||||||
const baseColor = project?.color || '#6B7280';
|
// For 0-duration events, display them with minimum visual duration but preserve actual duration
|
||||||
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
|
const startTime = getLocalizedDayJs(timeEntry.start);
|
||||||
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
|
const endTime =
|
||||||
|
duration === 0
|
||||||
|
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
|
||||||
|
: getLocalizedDayJs(timeEntry.end!);
|
||||||
|
|
||||||
// For 0-duration events, display them with minimum visual duration but preserve actual duration
|
return {
|
||||||
const startTime = getLocalizedDayJs(timeEntry.start);
|
id: timeEntry.id,
|
||||||
const endTime =
|
start: startTime.format(),
|
||||||
duration === 0
|
end: endTime.format(),
|
||||||
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
|
title,
|
||||||
: isRunning
|
backgroundColor,
|
||||||
? getLocalizedDayJs(currentTime.value.toISOString())
|
borderColor,
|
||||||
: getLocalizedDayJs(timeEntry.end!);
|
textColor: 'var(--foreground)',
|
||||||
|
extendedProps: {
|
||||||
return {
|
timeEntry,
|
||||||
id: timeEntry.id,
|
project,
|
||||||
start: startTime.format(),
|
client,
|
||||||
end: endTime.format(),
|
task,
|
||||||
title,
|
duration,
|
||||||
backgroundColor,
|
},
|
||||||
borderColor,
|
};
|
||||||
textColor: 'var(--foreground)',
|
});
|
||||||
// For running entries: disable dragging and resizing
|
|
||||||
startEditable: !isRunning,
|
|
||||||
classNames: isRunning ? ['running-entry'] : [],
|
|
||||||
extendedProps: {
|
|
||||||
timeEntry,
|
|
||||||
project,
|
|
||||||
client,
|
|
||||||
task,
|
|
||||||
duration,
|
|
||||||
isRunning,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Daily totals used in day header
|
// Daily totals used in day header
|
||||||
const dailyTotals = computed(() => {
|
const dailyTotals = computed(() => {
|
||||||
const totals: Record<string, number> = {};
|
const totals: Record<string, number> = {};
|
||||||
props.timeEntries.forEach((entry) => {
|
props.timeEntries
|
||||||
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
|
.filter((entry) => entry.end !== null)
|
||||||
let durationSeconds: number;
|
.forEach((entry) => {
|
||||||
|
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
|
||||||
if (entry.end !== null) {
|
const duration = getDayJsInstance()(entry.end!).diff(
|
||||||
// Completed entry
|
|
||||||
durationSeconds = getDayJsInstance()(entry.end).diff(
|
|
||||||
getDayJsInstance()(entry.start),
|
getDayJsInstance()(entry.start),
|
||||||
'seconds'
|
'minutes'
|
||||||
);
|
);
|
||||||
} else {
|
totals[date] = (totals[date] || 0) + duration;
|
||||||
// Running entry - use current time
|
});
|
||||||
durationSeconds = currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds');
|
|
||||||
}
|
|
||||||
|
|
||||||
totals[date] = (totals[date] || 0) + durationSeconds;
|
|
||||||
});
|
|
||||||
return totals;
|
return totals;
|
||||||
});
|
});
|
||||||
|
|
||||||
function emitDatesChange(arg: DatesSetArg) {
|
function emitDatesChange(arg: DatesSetArg) {
|
||||||
emit('dates-change', { start: arg.start, end: arg.end });
|
emit('dates-change', { start: arg.start, end: arg.end });
|
||||||
// Render activity boxes after calendar view has been rendered
|
|
||||||
renderActivityBoxes();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDateSelect(arg: { start: Date; end: Date }) {
|
function handleDateSelect(arg: { start: Date; end: Date }) {
|
||||||
@@ -219,10 +181,6 @@ function handleDateSelect(arg: { start: Date; end: Date }) {
|
|||||||
|
|
||||||
function handleEventClick(arg: EventClickArg) {
|
function handleEventClick(arg: EventClickArg) {
|
||||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||||
// Don't open edit modal for running time entries
|
|
||||||
if (ext.isRunning) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectedTimeEntry.value = ext.timeEntry;
|
selectedTimeEntry.value = ext.timeEntry;
|
||||||
showEditTimeEntryModal.value = true;
|
showEditTimeEntryModal.value = true;
|
||||||
}
|
}
|
||||||
@@ -236,13 +194,11 @@ async function handleEventDrop(arg: EventDropArg) {
|
|||||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||||
.utc()
|
.utc()
|
||||||
.tz(getUserTimezone(), true)
|
.tz(getUserTimezone(), true)
|
||||||
.second(0)
|
|
||||||
.utc()
|
.utc()
|
||||||
.format(),
|
.format(),
|
||||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||||
.utc()
|
.utc()
|
||||||
.tz(getUserTimezone(), true)
|
.tz(getUserTimezone(), true)
|
||||||
.second(0)
|
|
||||||
.utc()
|
.utc()
|
||||||
.format(),
|
.format(),
|
||||||
} as TimeEntry;
|
} as TimeEntry;
|
||||||
@@ -259,25 +215,20 @@ async function handleEventResize(arg: EventChangeArg) {
|
|||||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||||
.utc()
|
.utc()
|
||||||
.tz(getUserTimezone(), true)
|
.tz(getUserTimezone(), true)
|
||||||
.second(0)
|
|
||||||
.utc()
|
.utc()
|
||||||
.format(),
|
.format(),
|
||||||
// Preserve null end for running entries
|
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||||
end: ext.isRunning
|
.utc()
|
||||||
? null
|
.tz(getUserTimezone(), true)
|
||||||
: getDayJsInstance()(arg.event.end.toISOString())
|
.utc()
|
||||||
.utc()
|
.format(),
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.second(0)
|
|
||||||
.utc()
|
|
||||||
.format(),
|
|
||||||
} as TimeEntry;
|
} as TimeEntry;
|
||||||
await props.updateTimeEntry(updatedTimeEntry);
|
await props.updateTimeEntry(updatedTimeEntry);
|
||||||
emit('refresh');
|
emit('refresh');
|
||||||
}
|
}
|
||||||
|
|
||||||
const calendarOptions = computed(() => ({
|
const calendarOptions = computed(() => ({
|
||||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
|
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||||
initialView: 'timeGridWeek',
|
initialView: 'timeGridWeek',
|
||||||
headerToolbar: {
|
headerToolbar: {
|
||||||
left: 'prev,next today',
|
left: 'prev,next today',
|
||||||
@@ -290,11 +241,10 @@ const calendarOptions = computed(() => ({
|
|||||||
slotDuration: '00:15:00',
|
slotDuration: '00:15:00',
|
||||||
slotLabelInterval: '01:00:00',
|
slotLabelInterval: '01:00:00',
|
||||||
slotLabelFormat: getSlotLabelFormat(),
|
slotLabelFormat: getSlotLabelFormat(),
|
||||||
snapDuration: '00:01:00',
|
snapDuration: '00:15:00',
|
||||||
firstDay: getFirstDay(),
|
firstDay: getFirstDay(),
|
||||||
allDaySlot: false,
|
allDaySlot: false,
|
||||||
nowIndicator: true,
|
nowIndicator: true,
|
||||||
eventMinHeight: 1,
|
|
||||||
selectable: true,
|
selectable: true,
|
||||||
selectMirror: true,
|
selectMirror: true,
|
||||||
editable: true,
|
editable: true,
|
||||||
@@ -309,7 +259,6 @@ const calendarOptions = computed(() => ({
|
|||||||
datesSet: emitDatesChange,
|
datesSet: emitDatesChange,
|
||||||
|
|
||||||
events: events.value,
|
events: events.value,
|
||||||
activityPeriods: props.activityPeriods || [],
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
watch(showCreateTimeEntryModal, (value) => {
|
watch(showCreateTimeEntryModal, (value) => {
|
||||||
@@ -328,60 +277,6 @@ watch(showEditTimeEntryModal, (value) => {
|
|||||||
emit('refresh');
|
emit('refresh');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render activity status boxes after FullCalendar has rendered
|
|
||||||
const renderActivityBoxes = () => {
|
|
||||||
if (!calendarRef.value || !props.activityPeriods) return;
|
|
||||||
|
|
||||||
const calendarEl = calendarRef.value.$el as HTMLElement;
|
|
||||||
if (calendarEl && props.activityPeriods.length > 0) {
|
|
||||||
renderActivityStatusBoxes(calendarEl, props.activityPeriods);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Watch for activity periods changes - re-render when data changes
|
|
||||||
watch(
|
|
||||||
() => props.activityPeriods,
|
|
||||||
() => {
|
|
||||||
renderActivityBoxes();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const scrollToCurrentTime = () => {
|
|
||||||
nextTick(() => {
|
|
||||||
if (calendarRef.value) {
|
|
||||||
const now = getDayJsInstance()();
|
|
||||||
const oneHourBefore = now.subtract(1, 'hour');
|
|
||||||
|
|
||||||
// If subtracting 1 hour keeps us on the same day, scroll to 1 hour before
|
|
||||||
const scrollTime = now.isSame(oneHourBefore, 'day')
|
|
||||||
? oneHourBefore.format('HH:mm:ss')
|
|
||||||
: now.format('HH:mm:ss');
|
|
||||||
|
|
||||||
calendarRef.value.getApi().scrollToTime(scrollTime);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
scrollToCurrentTime();
|
|
||||||
// Start interval to update running time entry
|
|
||||||
currentTimeInterval = setInterval(() => {
|
|
||||||
currentTime.value = getDayJsInstance()();
|
|
||||||
}, 60000); // Update every minute
|
|
||||||
});
|
|
||||||
|
|
||||||
onActivated(() => {
|
|
||||||
scrollToCurrentTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
// Clean up interval
|
|
||||||
if (currentTimeInterval) {
|
|
||||||
clearInterval(currentTimeInterval);
|
|
||||||
currentTimeInterval = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -400,8 +295,6 @@ onUnmounted(() => {
|
|||||||
:create-client="createClient"
|
:create-client="createClient"
|
||||||
:create-project="createProject"
|
:create-project="createProject"
|
||||||
:create-tag="createTag"
|
:create-tag="createTag"
|
||||||
:currency="currency"
|
|
||||||
:can-create-project="canCreateProject"
|
|
||||||
:tags="tags as any"
|
:tags="tags as any"
|
||||||
:projects="projects"
|
:projects="projects"
|
||||||
:tasks="tasks"
|
:tasks="tasks"
|
||||||
@@ -421,9 +314,7 @@ onUnmounted(() => {
|
|||||||
:tags="tags as any"
|
:tags="tags as any"
|
||||||
:projects="projects"
|
:projects="projects"
|
||||||
:tasks="tasks"
|
:tasks="tasks"
|
||||||
:clients="clients"
|
:clients="clients" />
|
||||||
:currency="currency"
|
|
||||||
:can-create-project="canCreateProject" />
|
|
||||||
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
|
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
|
||||||
<template #eventContent="arg">
|
<template #eventContent="arg">
|
||||||
<FullCalendarEventContent
|
<FullCalendarEventContent
|
||||||
@@ -444,7 +335,7 @@ onUnmounted(() => {
|
|||||||
:date="
|
:date="
|
||||||
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
|
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
|
||||||
"
|
"
|
||||||
:total-seconds="
|
:total-minutes="
|
||||||
dailyTotals[
|
dailyTotals[
|
||||||
getDayJsInstance()(arg.date)
|
getDayJsInstance()(arg.date)
|
||||||
.utc()
|
.utc()
|
||||||
@@ -476,11 +367,11 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-slot-label) {
|
.fullcalendar :deep(.fc-timegrid-slot-label) {
|
||||||
background-color: var(--background);
|
background-color: var(--theme-color-default-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-toolbar) {
|
.fullcalendar :deep(.fc-toolbar) {
|
||||||
background-color: var(--background);
|
background-color: var(--theme-color-default-background);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -548,7 +439,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-day-today.fc-col-header-cell) {
|
.fullcalendar :deep(.fc-day-today.fc-col-header-cell) {
|
||||||
background-color: var(--color-bg-secondary);
|
background-color: var(--color-accent-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-day-today) {
|
.fullcalendar :deep(.fc-day-today) {
|
||||||
@@ -561,8 +452,8 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-event) {
|
.fullcalendar :deep(.fc-event) {
|
||||||
border-radius: calc(var(--radius) - 4px);
|
border-radius: var(--radius);
|
||||||
padding: 0;
|
padding: 0.45rem 0.25rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: var(--theme-shadow-card);
|
box-shadow: var(--theme-shadow-card);
|
||||||
@@ -624,7 +515,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-highlight) {
|
.fullcalendar :deep(.fc-highlight) {
|
||||||
background-color: var(--primary);
|
background-color: var(--theme-color-default-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-select-mirror) {
|
.fullcalendar :deep(.fc-select-mirror) {
|
||||||
@@ -642,7 +533,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-body) {
|
.fullcalendar :deep(.fc-timegrid-body) {
|
||||||
background-color: var(--background);
|
background-color: var(--theme-color-default-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-col) {
|
.fullcalendar :deep(.fc-timegrid-col) {
|
||||||
@@ -709,57 +600,4 @@ onUnmounted(() => {
|
|||||||
.fullcalendar :deep(.fc-event-main) {
|
.fullcalendar :deep(.fc-event-main) {
|
||||||
padding: 0.125rem 0.25rem;
|
padding: 0.125rem 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Activity status plugin styles */
|
|
||||||
.fullcalendar :deep(.activity-status-box) {
|
|
||||||
position: absolute;
|
|
||||||
width: 10px;
|
|
||||||
left: 0px;
|
|
||||||
z-index: 10;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.activity-status-box::before) {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 5px;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.activity-status-box.idle::before) {
|
|
||||||
background-color: rgba(156, 163, 175, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.activity-status-box.idle):hover::before {
|
|
||||||
background-color: rgba(156, 163, 175, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.activity-status-box.active::before) {
|
|
||||||
background-color: rgba(34, 197, 94, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.activity-status-box.active):hover::before {
|
|
||||||
background-color: rgba(34, 197, 94, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add left margin to events only on days with activity status data */
|
|
||||||
.fullcalendar :deep(.has-activity-status .fc-timegrid-event-harness) {
|
|
||||||
margin-left: 8px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-event) {
|
|
||||||
margin-left: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide end resizer for running time entries */
|
|
||||||
.fullcalendar :deep(.running-entry .fc-event-resizer-end) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.running-entry) {
|
|
||||||
border-bottom-left-radius: 0px;
|
|
||||||
border-bottom-right-radius: 0px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,393 +0,0 @@
|
|||||||
import { createPlugin, type PluginDef } from '@fullcalendar/core';
|
|
||||||
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
|
|
||||||
|
|
||||||
export interface WindowActivityInPeriod {
|
|
||||||
appName: string;
|
|
||||||
url: string | null;
|
|
||||||
count: number;
|
|
||||||
icon?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActivityPeriod {
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
isIdle: boolean;
|
|
||||||
windowActivities?: WindowActivityInPeriod[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActivityStatusPluginOptions {
|
|
||||||
activityPeriods?: ActivityPeriod[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tooltip state management - single instance per module
|
|
||||||
let tooltipInstance: HTMLElement | null = null;
|
|
||||||
let cleanupAutoUpdate: (() => void) | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and manages a tooltip element for activity status boxes
|
|
||||||
*/
|
|
||||||
function getOrCreateTooltip(): HTMLElement {
|
|
||||||
if (!tooltipInstance) {
|
|
||||||
tooltipInstance = document.createElement('div');
|
|
||||||
tooltipInstance.className =
|
|
||||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';
|
|
||||||
tooltipInstance.style.position = 'fixed';
|
|
||||||
tooltipInstance.style.pointerEvents = 'none';
|
|
||||||
tooltipInstance.style.opacity = '0';
|
|
||||||
tooltipInstance.style.whiteSpace = 'nowrap';
|
|
||||||
tooltipInstance.style.transform = 'scale(0.95)';
|
|
||||||
tooltipInstance.style.transition = 'opacity 150ms, transform 150ms';
|
|
||||||
document.body.appendChild(tooltipInstance);
|
|
||||||
}
|
|
||||||
return tooltipInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows tooltip for an activity status box using Floating UI's autoUpdate
|
|
||||||
*/
|
|
||||||
function showTooltip(box: HTMLElement, tooltip: HTMLElement, content: string | HTMLElement) {
|
|
||||||
// Clear previous content
|
|
||||||
tooltip.innerHTML = '';
|
|
||||||
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
tooltip.textContent = content;
|
|
||||||
} else {
|
|
||||||
tooltip.appendChild(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
tooltip.style.opacity = '1';
|
|
||||||
tooltip.style.transform = 'scale(1)';
|
|
||||||
|
|
||||||
// Clean up previous autoUpdate if it exists
|
|
||||||
if (cleanupAutoUpdate) {
|
|
||||||
cleanupAutoUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use autoUpdate to automatically update position
|
|
||||||
cleanupAutoUpdate = autoUpdate(box, tooltip, () => {
|
|
||||||
computePosition(box, tooltip, {
|
|
||||||
placement: 'right',
|
|
||||||
middleware: [offset(8), flip(), shift({ padding: 5 })],
|
|
||||||
}).then(({ x, y }) => {
|
|
||||||
tooltip.style.left = `${x}px`;
|
|
||||||
tooltip.style.top = `${y}px`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hides the tooltip immediately
|
|
||||||
*/
|
|
||||||
function hideTooltip(tooltip: HTMLElement) {
|
|
||||||
tooltip.style.opacity = '0';
|
|
||||||
tooltip.style.transform = 'scale(0.95)';
|
|
||||||
|
|
||||||
// Clean up autoUpdate when tooltip is hidden
|
|
||||||
if (cleanupAutoUpdate) {
|
|
||||||
cleanupAutoUpdate();
|
|
||||||
cleanupAutoUpdate = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats duration in minutes to human readable format
|
|
||||||
*/
|
|
||||||
function formatDuration(durationMinutes: number): string {
|
|
||||||
const hours = Math.floor(durationMinutes / 60);
|
|
||||||
const minutes = durationMinutes % 60;
|
|
||||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates tooltip content for an activity period
|
|
||||||
*/
|
|
||||||
function createTooltipContent(
|
|
||||||
status: string,
|
|
||||||
durationText: string,
|
|
||||||
windowActivities?: WindowActivityInPeriod[]
|
|
||||||
): string | HTMLElement {
|
|
||||||
if (!windowActivities || windowActivities.length === 0) {
|
|
||||||
return `${status} (${durationText})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = document.createElement('div');
|
|
||||||
container.style.maxWidth = '300px';
|
|
||||||
|
|
||||||
// Header with status and duration
|
|
||||||
const header = document.createElement('div');
|
|
||||||
header.style.fontWeight = '600';
|
|
||||||
header.style.marginBottom = '8px';
|
|
||||||
header.textContent = `${status} (${durationText})`;
|
|
||||||
container.appendChild(header);
|
|
||||||
|
|
||||||
// Window activities list
|
|
||||||
const totalActivities = windowActivities.reduce((sum, act) => sum + act.count, 0);
|
|
||||||
|
|
||||||
// Show top 5 activities
|
|
||||||
const topActivities = windowActivities.slice(0, 5);
|
|
||||||
|
|
||||||
topActivities.forEach((activity) => {
|
|
||||||
const activityDiv = document.createElement('div');
|
|
||||||
activityDiv.style.marginTop = '4px';
|
|
||||||
activityDiv.style.fontSize = '11px';
|
|
||||||
activityDiv.style.opacity = '0.9';
|
|
||||||
activityDiv.style.display = 'flex';
|
|
||||||
activityDiv.style.alignItems = 'center';
|
|
||||||
activityDiv.style.gap = '6px';
|
|
||||||
|
|
||||||
// Add icon if available
|
|
||||||
if (activity.icon) {
|
|
||||||
const icon = document.createElement('img');
|
|
||||||
icon.src = activity.icon;
|
|
||||||
icon.alt = activity.appName;
|
|
||||||
icon.style.width = '16px';
|
|
||||||
icon.style.height = '16px';
|
|
||||||
icon.style.borderRadius = '2px';
|
|
||||||
icon.style.flexShrink = '0';
|
|
||||||
activityDiv.appendChild(icon);
|
|
||||||
} else {
|
|
||||||
// Placeholder for no icon
|
|
||||||
const placeholder = document.createElement('div');
|
|
||||||
placeholder.style.width = '16px';
|
|
||||||
placeholder.style.height = '16px';
|
|
||||||
placeholder.style.borderRadius = '2px';
|
|
||||||
placeholder.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
|
|
||||||
placeholder.style.display = 'flex';
|
|
||||||
placeholder.style.alignItems = 'center';
|
|
||||||
placeholder.style.justifyContent = 'center';
|
|
||||||
placeholder.style.fontSize = '8px';
|
|
||||||
placeholder.style.flexShrink = '0';
|
|
||||||
placeholder.textContent = activity.appName.charAt(0).toUpperCase();
|
|
||||||
activityDiv.appendChild(placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
const textSpan = document.createElement('span');
|
|
||||||
textSpan.style.flex = '1';
|
|
||||||
textSpan.style.overflow = 'hidden';
|
|
||||||
textSpan.style.textOverflow = 'ellipsis';
|
|
||||||
textSpan.style.whiteSpace = 'nowrap';
|
|
||||||
|
|
||||||
const percentage = ((activity.count / totalActivities) * 100).toFixed(0);
|
|
||||||
const activityText = activity.url
|
|
||||||
? `${activity.appName} - ${activity.url}`
|
|
||||||
: activity.appName;
|
|
||||||
|
|
||||||
textSpan.textContent = `${percentage}% ${activityText}`;
|
|
||||||
activityDiv.appendChild(textSpan);
|
|
||||||
|
|
||||||
container.appendChild(activityDiv);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show "and X more" if there are more activities
|
|
||||||
if (windowActivities.length > 5) {
|
|
||||||
const moreDiv = document.createElement('div');
|
|
||||||
moreDiv.style.marginTop = '4px';
|
|
||||||
moreDiv.style.fontSize = '11px';
|
|
||||||
moreDiv.style.opacity = '0.7';
|
|
||||||
moreDiv.style.fontStyle = 'italic';
|
|
||||||
moreDiv.textContent = `...and ${windowActivities.length - 5} more`;
|
|
||||||
container.appendChild(moreDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders activity status boxes in the calendar time grid
|
|
||||||
*/
|
|
||||||
export function renderActivityStatusBoxes(
|
|
||||||
calendarEl: HTMLElement,
|
|
||||||
activityPeriods: ActivityPeriod[]
|
|
||||||
) {
|
|
||||||
if (!calendarEl) return;
|
|
||||||
|
|
||||||
// Clean up existing activity boxes
|
|
||||||
const existingBoxes = calendarEl.querySelectorAll('.activity-status-box');
|
|
||||||
existingBoxes.forEach((box) => box.remove());
|
|
||||||
|
|
||||||
// Remove has-activity-status class from all lanes
|
|
||||||
const allLanes = calendarEl.querySelectorAll('.fc-timegrid-col');
|
|
||||||
allLanes.forEach((lane) => lane.classList.remove('has-activity-status'));
|
|
||||||
|
|
||||||
const timeGrid = calendarEl.querySelector('.fc-timegrid-body');
|
|
||||||
if (!timeGrid) return;
|
|
||||||
|
|
||||||
const lanes = timeGrid.querySelectorAll('.fc-timegrid-col');
|
|
||||||
if (lanes.length === 0) return;
|
|
||||||
|
|
||||||
// Get or reuse the single tooltip instance
|
|
||||||
const tooltip = getOrCreateTooltip();
|
|
||||||
|
|
||||||
// Get slot duration from calendar (fallback to 15 minutes)
|
|
||||||
const slotDurationMinutes = getSlotDuration(calendarEl);
|
|
||||||
|
|
||||||
lanes.forEach((lane: Element) => {
|
|
||||||
// Get the date for this lane from the data attribute
|
|
||||||
const laneEl = lane as HTMLElement;
|
|
||||||
const dateStr = laneEl.getAttribute('data-date');
|
|
||||||
|
|
||||||
if (!dateStr) return;
|
|
||||||
|
|
||||||
const laneDate = new Date(dateStr);
|
|
||||||
const laneDateStart = new Date(laneDate);
|
|
||||||
laneDateStart.setHours(0, 0, 0, 0);
|
|
||||||
const laneDateEnd = new Date(laneDate);
|
|
||||||
laneDateEnd.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
let hasActivityStatusForThisDay = false;
|
|
||||||
|
|
||||||
activityPeriods.forEach((period) => {
|
|
||||||
const periodStart = new Date(period.start);
|
|
||||||
const periodEnd = new Date(period.end);
|
|
||||||
|
|
||||||
// Check if period overlaps with this day
|
|
||||||
if (periodEnd < laneDateStart || periodStart > laneDateEnd) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate actual start and end times for this day
|
|
||||||
const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;
|
|
||||||
const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;
|
|
||||||
|
|
||||||
// Calculate the position and height of the activity box
|
|
||||||
const { top, height } = calculateBoxPosition(
|
|
||||||
calendarEl,
|
|
||||||
actualStart,
|
|
||||||
actualEnd,
|
|
||||||
slotDurationMinutes
|
|
||||||
);
|
|
||||||
|
|
||||||
if (height <= 0) return;
|
|
||||||
|
|
||||||
hasActivityStatusForThisDay = true;
|
|
||||||
|
|
||||||
// Calculate duration in minutes
|
|
||||||
const durationMs = actualEnd.getTime() - actualStart.getTime();
|
|
||||||
const durationMinutes = Math.round(durationMs / 60000);
|
|
||||||
const durationText = formatDuration(durationMinutes);
|
|
||||||
|
|
||||||
// Add tooltip text based on status
|
|
||||||
const status = period.isIdle ? 'Idling' : 'Active';
|
|
||||||
|
|
||||||
// Create and append the activity status box
|
|
||||||
const box = document.createElement('div');
|
|
||||||
box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;
|
|
||||||
box.style.top = `${top}px`;
|
|
||||||
box.style.height = `${height}px`;
|
|
||||||
|
|
||||||
// Store tooltip content generator in data attribute for event delegation
|
|
||||||
const tooltipContent = createTooltipContent(
|
|
||||||
status,
|
|
||||||
durationText,
|
|
||||||
period.windowActivities
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add hover event listeners for tooltip
|
|
||||||
box.addEventListener('mouseenter', () => {
|
|
||||||
showTooltip(box, tooltip, tooltipContent);
|
|
||||||
});
|
|
||||||
|
|
||||||
box.addEventListener('mouseleave', () => {
|
|
||||||
hideTooltip(tooltip);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Position relative to the lane
|
|
||||||
const laneFrame = lane.querySelector('.fc-timegrid-col-frame');
|
|
||||||
if (laneFrame) {
|
|
||||||
laneFrame.appendChild(box);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark this lane as having activity status if any periods were rendered
|
|
||||||
if (hasActivityStatusForThisDay) {
|
|
||||||
laneEl.classList.add('has-activity-status');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the slot duration from the calendar configuration
|
|
||||||
*/
|
|
||||||
function getSlotDuration(calendarEl: HTMLElement): number {
|
|
||||||
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
|
|
||||||
if (slotsEl.length < 2) return 15; // Default to 15 minutes
|
|
||||||
|
|
||||||
// Try to calculate from the time difference between slots
|
|
||||||
const firstSlot = slotsEl[0] as HTMLElement;
|
|
||||||
const secondSlot = slotsEl[1] as HTMLElement;
|
|
||||||
|
|
||||||
const firstTime = firstSlot.getAttribute('data-time');
|
|
||||||
const secondTime = secondSlot.getAttribute('data-time');
|
|
||||||
|
|
||||||
if (firstTime && secondTime) {
|
|
||||||
const [h1, m1] = firstTime.split(':').map(Number);
|
|
||||||
const [h2, m2] = secondTime.split(':').map(Number);
|
|
||||||
const diff = h2 * 60 + m2 - (h1 * 60 + m1);
|
|
||||||
if (diff > 0) return diff;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to 15 minutes
|
|
||||||
return 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the pixel position and height for an activity status box
|
|
||||||
*/
|
|
||||||
function calculateBoxPosition(
|
|
||||||
calendarEl: HTMLElement,
|
|
||||||
startTime: Date,
|
|
||||||
endTime: Date,
|
|
||||||
slotDurationMinutes: number
|
|
||||||
): { top: number; height: number } {
|
|
||||||
// Get the slot duration and slot height
|
|
||||||
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
|
|
||||||
if (slotsEl.length === 0) {
|
|
||||||
return { top: 0, height: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate slot height (assuming all slots are equal height)
|
|
||||||
const firstSlot = slotsEl[0] as HTMLElement;
|
|
||||||
const slotHeight = firstSlot.offsetHeight;
|
|
||||||
|
|
||||||
const pixelsPerMinute = slotHeight / slotDurationMinutes;
|
|
||||||
|
|
||||||
// Calculate start position (minutes from midnight)
|
|
||||||
const startMinutes = startTime.getHours() * 60 + startTime.getMinutes();
|
|
||||||
const endMinutes = endTime.getHours() * 60 + endTime.getMinutes();
|
|
||||||
|
|
||||||
// Calculate pixel positions
|
|
||||||
const top = startMinutes * pixelsPerMinute;
|
|
||||||
const height = (endMinutes - startMinutes) * pixelsPerMinute;
|
|
||||||
|
|
||||||
return { top, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup function to remove tooltip from DOM
|
|
||||||
*/
|
|
||||||
export function cleanupActivityStatusPlugin() {
|
|
||||||
if (tooltipInstance) {
|
|
||||||
tooltipInstance.remove();
|
|
||||||
tooltipInstance = null;
|
|
||||||
}
|
|
||||||
if (cleanupAutoUpdate) {
|
|
||||||
cleanupAutoUpdate();
|
|
||||||
cleanupAutoUpdate = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FullCalendar plugin to display idle/active status boxes in the time grid
|
|
||||||
*/
|
|
||||||
const activityStatusPlugin: PluginDef = createPlugin({
|
|
||||||
name: '@solidtime/activity-status',
|
|
||||||
|
|
||||||
optionRefiners: {
|
|
||||||
activityPeriods: (rawVal: unknown): ActivityPeriod[] => {
|
|
||||||
if (!Array.isArray(rawVal)) return [];
|
|
||||||
return rawVal as ActivityPeriod[];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default activityStatusPlugin;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user