Compare commits

..

24 Commits

Author SHA1 Message Date
Gregor Vostrak
0ec3b732ad allow employee manage task setting to organization 2025-11-25 15:27:47 +01:00
Gregor Vostrak
b1bb7245b0 use default api limit for fetching time entries 2025-11-20 17:30:13 +01:00
Gregor Vostrak
6f37ad500a limit initially loaded time entries on time page 2025-11-20 16:58:53 +01:00
Gregor Vostrak
500ccd5719 fix container queries for time entry rows 2025-11-20 16:52:08 +01:00
Gregor Vostrak
bacd6f4222 include the currently running time entry in the calendar header 2025-11-20 13:17:48 +01:00
Gregor Vostrak
022caf59ee bump solidtime ui package version to 0.0.13 2025-11-19 17:34:21 +01:00
Gregor Vostrak
f955ab3135 fix display problems caused by minimum height of calendar events 2025-11-19 17:34:21 +01:00
Gregor Vostrak
5b491b0da2 add support for currently running time entry 2025-11-19 17:34:21 +01:00
Gregor Vostrak
249ab67ac8 improve idle indicator colors, fix typescript issues 2025-11-19 17:34:21 +01:00
Gregor Vostrak
1bd2c28b37 add tooltips to idlestatus indicators 2025-11-19 17:34:21 +01:00
Gregor Vostrak
33ac994cc0 add activity status plugin to calendar 2025-11-19 17:34:21 +01:00
Gregor Vostrak
8d3ee58bed improve initial mount performance for groupedtimeentrytable by streaming in the rows
mounting the rows mounts lots of nested components which results in a delay on the initial mount.
2025-11-19 17:34:21 +01:00
Gregor Vostrak
8a2c260533 use container queries for time entry table 2025-11-19 17:34:21 +01:00
Gregor Vostrak
95ab1699c4 make sure that CreateTimeEntry modal always starts with times that have 0 seconds 2025-11-19 17:34:21 +01:00
Gregor Vostrak
306a081a3d prevent seconds update on timepicker when nothing else changes 2025-11-19 17:34:21 +01:00
Gregor Vostrak
878ac4ab81 add tooltip component 2025-11-19 17:34:21 +01:00
Gregor Vostrak
947550d639 move css variables and tailwind theme config into ui package 2025-11-19 17:34:21 +01:00
Gregor Vostrak
09fb5aa48e make sure that timepicker and calendar set seconds to 0 on update, fixes #968 2025-11-19 17:34:21 +01:00
Gregor Vostrak
9b9371e5a5 move button component to ui package 2025-11-19 17:34:21 +01:00
Gregor Vostrak
0648437478 design fixes, improve component encapsulation 2025-11-19 17:34:21 +01:00
Gregor Vostrak
8ba04eca0c move currency and cancreateproject permission to props to decouple TimeEntryCreateModal from web 2025-11-19 17:34:21 +01:00
Gregor Vostrak
8a2f35de0c fix package build error dependencies 2025-11-19 17:34:21 +01:00
Gregor Vostrak
b7dafb0892 bump api and ui package versions 2025-11-19 17:34:21 +01:00
Gregor Vostrak
6eca0c2c76 fix archived_at timestamp of client in exporter 2025-11-11 12:55:33 +01:00
64 changed files with 2895 additions and 1288 deletions

View File

@@ -46,6 +46,9 @@ 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();
} }

View File

@@ -11,6 +11,7 @@ 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;
@@ -27,6 +28,26 @@ 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
* *
@@ -75,7 +96,15 @@ class TaskController extends Controller
*/ */
public function store(Organization $organization, TaskStoreRequest $request): JsonResource public function store(Organization $organization, TaskStoreRequest $request): JsonResource
{ {
$this->checkPermission($organization, 'tasks:create'); /** @var Project $project */
$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');
@@ -97,7 +126,17 @@ class TaskController extends Controller
*/ */
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
{ {
$this->checkPermission($organization, 'tasks:update', $task); // Check task belongs to organization
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();
@@ -119,7 +158,16 @@ class TaskController extends Controller
*/ */
public function destroy(Organization $organization, Task $task): JsonResponse public function destroy(Organization $organization, Task $task): JsonResponse
{ {
$this->checkPermission($organization, 'tasks:delete', $task); // Check task belongs to organization
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');

View File

@@ -39,6 +39,9 @@ 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',
], ],
@@ -102,6 +105,11 @@ 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;

View File

@@ -53,6 +53,8 @@ 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) */

View File

@@ -35,6 +35,7 @@ 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
@@ -70,6 +71,7 @@ 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,

View File

@@ -94,8 +94,11 @@ 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',
@@ -158,8 +161,11 @@ 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',
@@ -219,8 +225,11 @@ 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',

View File

@@ -167,7 +167,7 @@ class ExportService
$client->id, $client->id,
$client->name, $client->name,
$client->organization_id, $client->organization_id,
$client->archived_at ?? '', $client->archived_at?->toIso8601ZuluString() ?? '',
$client->created_at?->toIso8601ZuluString() ?? '', $client->created_at?->toIso8601ZuluString() ?? '',
$client->updated_at?->toIso8601ZuluString() ?? '', $client->updated_at?->toIso8601ZuluString() ?? '',
]); ]);

View File

@@ -71,7 +71,19 @@ class PermissionStore
/** @var Role|null $roleObj */ /** @var Role|null $roleObj */
$roleObj = Jetstream::findRole($role); $roleObj = Jetstream::findRole($role);
return $roleObj->permissions ?? []; $permissions = $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;
} }
/** /**

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('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');
});
}
};

View File

@@ -1,240 +1,14 @@
@tailwind base; /* Import shared solidtime styles from UI package */
@tailwind components; @import '../js/packages/ui/styles.css';
@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;
--color-border-primary: #191b1f; /* Main app specific styles - Inter font */
--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: url('/fonts/InterVariable.woff2') format('woff2'), src:
url('/fonts/InterVariable.ttf') format('truetype'); url('/fonts/InterVariable.woff2') format('woff2'),
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;
}
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button } from '@/Components/ui/button'; import { Button } from '@/packages/ui/src';
const props = defineProps<{ const props = defineProps<{
icon: Component; icon: Component;

View File

@@ -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 '@/Components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button'; import { Button } from '@/packages/ui/src';
import { import {
Select, Select,
SelectContent, SelectContent,

View File

@@ -158,6 +158,7 @@ async function discardCurrentTimeEntry() {
} }
const { tags } = storeToRefs(useTagsStore()); const { tags } = storeToRefs(useTagsStore());
const { timeEntries } = storeToRefs(useTimeEntriesStore());
</script> </script>
<template> <template>
@@ -168,6 +169,8 @@ const { tags } = storeToRefs(useTagsStore());
:create-client="createClient" :create-client="createClient"
:create-tag="createTag" :create-tag="createTag"
:create-time-entry="createTimeEntry" :create-time-entry="createTimeEntry"
:currency="getOrganizationCurrencyString()"
:can-create-project="canCreateProjects()"
:projects :projects
:tasks :tasks
:tags :tags
@@ -194,6 +197,7 @@ const { tags } = storeToRefs(useTagsStore());
:tags :tags
:tasks :tasks
:projects :projects
:time-entries
:create-tag :create-tag
:is-active :is-active
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { buttonVariants } from '@/Components/ui/button'; import { buttonVariants } from '@/packages/ui/src';
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';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { buttonVariants } from '@/Components/ui/button'; import { buttonVariants } from '@/packages/ui/src';
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';

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { cn } from '@/lib/utils'; import { cn, buttonVariants } from '@/packages/ui/src';
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';

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button'; import { Button } from '@/packages/ui/src';
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';

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { cn } from '@/lib/utils'; import { cn, buttonVariants } from '@/packages/ui/src/index';
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';

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { cn } from '@/lib/utils'; import { cn, buttonVariants } from '@/packages/ui/src';
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';

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { cn } from '@/lib/utils'; import { cn, buttonVariants } from '@/packages/ui/src';
import { buttonVariants } from '@/Components/ui/button';
import { import {
RangeCalendarCellTrigger, RangeCalendarCellTrigger,
type RangeCalendarCellTriggerProps, type RangeCalendarCellTriggerProps,

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { cn } from '@/lib/utils'; import { cn, buttonVariants } from '@/packages/ui/src';
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';

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { cn } from '@/lib/utils'; import { cn, buttonVariants } from '@/packages/ui/src';
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';

View File

@@ -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 '@/Components/ui/button/Button.vue'; import { Button } from '@/packages/ui/src';
import { openFeedback } from '@/utils/feedback'; import { openFeedback } from '@/utils/feedback';
defineProps({ defineProps({

View File

@@ -21,6 +21,8 @@ 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);
@@ -129,6 +131,8 @@ 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"

View File

@@ -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"> <div class="w-full relative @container">
<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)"

View File

@@ -14,13 +14,18 @@ const { updateOrganization } = store;
const { organization } = storeToRefs(store); const { organization } = storeToRefs(store);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const form = ref<{ prevent_overlapping_time_entries: boolean }>({ const form = ref<{
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({
@@ -33,22 +38,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>Time Entry Settings</template> <template #title>Organization Settings</template>
<template #description> <template #description>
Disallow overlapping time entries for members of this organization. When enabled, users Configure various settings for your organization, including time entry and task
cannot create new time entries that overlap with their existing ones. This only affects management permissions.
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"> <div class="col-span-6 sm:col-span-4 space-y-4">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Checkbox <Checkbox
id="preventOverlappingTimeEntries" id="preventOverlappingTimeEntries"
@@ -57,6 +62,14 @@ 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>

View File

@@ -1,12 +1,12 @@
{ {
"name": "@solidtime/api", "name": "@solidtime/api",
"version": "0.0.4", "version": "0.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@solidtime/api", "name": "@solidtime/api",
"version": "0.0.3", "version": "0.0.5",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@solidtime/api", "name": "@solidtime/api",
"version": "0.0.4", "version": "0.0.5",
"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",

View File

@@ -317,6 +317,7 @@ 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(),
@@ -332,6 +333,7 @@ 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,

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@solidtime/ui", "name": "@solidtime/ui",
"version": "0.0.10", "version": "0.0.13",
"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,11 +21,14 @@
"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"
}, },
@@ -59,8 +62,11 @@
"@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",

View File

@@ -0,0 +1,25 @@
<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>

View File

@@ -0,0 +1,36 @@
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>;

View File

@@ -23,7 +23,7 @@ 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>{{ formatDate(date.toISOString(), dateFormat) }}</span> <span class="text-xs">{{ 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(totalSeconds, intervalFormat, numberFormat) }} {{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
</span> </span>

View File

@@ -40,18 +40,18 @@ const formattedDuration = computed(() =>
</script> </script>
<template> <template>
<div class="text-xs leading-tight"> <div class="text-2xs leading-tight px-0.5 py-1.5">
<div class="font-semibold mb-0.5">{{ title }}</div> <div class="font-semibold">{{ title }}</div>
<div v-if="projectName" class="font-medium text-[0.6875rem] opacity-90"> <div v-if="projectName" class="font-medium opacity-90">
{{ projectName }} {{ projectName }}
</div> </div>
<div v-if="taskName" class="font-medium text-[0.6875rem] opacity-90"> <div v-if="taskName" class="font-medium">
{{ taskName }} {{ taskName }}
</div> </div>
<div v-if="clientName" class="text-[0.625rem] italic opacity-85"> <div v-if="clientName" class="opacity-85">
{{ clientName }} {{ clientName }}
</div> </div>
<div class="text-[0.625rem] font-semibold opacity-90 mt-0.5"> <div class="opacity-90">
{{ formattedDuration }} {{ formattedDuration }}
</div> </div>
</div> </div>

View File

@@ -4,7 +4,17 @@ 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 { computed, ref, watch, inject, type ComputedRef } from 'vue'; import {
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';
@@ -12,6 +22,10 @@ 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,
@@ -24,7 +38,10 @@ import type {
} from '@/packages/api/src'; } from '@/packages/api/src';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
type CalendarExtendedProps = { timeEntry: TimeEntry } & Record<string, unknown>; type CalendarExtendedProps = { timeEntry: TimeEntry; isRunning?: boolean } & Record<
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;
@@ -37,10 +54,13 @@ 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'>
@@ -61,6 +81,10 @@ 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');
@@ -102,67 +126,81 @@ const events = computed(() => {
const themeBackground = (() => { const themeBackground = (() => {
return cssBackground.value?.trim(); return cssBackground.value?.trim();
})(); })();
return props.timeEntries return props.timeEntries?.map((timeEntry) => {
?.filter((timeEntry) => timeEntry.end !== null) const isRunning = timeEntry.end === null;
?.map((timeEntry) => { const project = props.projects.find((p) => p.id === timeEntry.project_id);
const project = props.projects.find((p) => p.id === timeEntry.project_id); const client = props.clients.find((c) => c.id === project?.client_id);
const client = props.clients.find((c) => c.id === project?.client_id); const task = props.tasks.find((t) => t.id === timeEntry.task_id);
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
const duration = getDayJsInstance()(timeEntry.end!).diff(
getDayJsInstance()(timeEntry.start),
'minutes'
);
const title = timeEntry.description || 'No description'; // For running entries, use current time as end
const effectiveEnd = isRunning ? currentTime.value : getDayJsInstance()(timeEntry.end!);
const duration = effectiveEnd.diff(getDayJsInstance()(timeEntry.start), 'minutes');
const baseColor = project?.color || '#6B7280'; const title = timeEntry.description || 'No description';
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
// For 0-duration events, display them with minimum visual duration but preserve actual duration const baseColor = project?.color || '#6B7280';
const startTime = getLocalizedDayJs(timeEntry.start); const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
const endTime = const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
duration === 0
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
: getLocalizedDayJs(timeEntry.end!);
return { // For 0-duration events, display them with minimum visual duration but preserve actual duration
id: timeEntry.id, const startTime = getLocalizedDayJs(timeEntry.start);
start: startTime.format(), const endTime =
end: endTime.format(), duration === 0
title, ? startTime.add(1, 'second') // Show as 1 second for minimal visibility
backgroundColor, : isRunning
borderColor, ? getLocalizedDayJs(currentTime.value.toISOString())
textColor: 'var(--foreground)', : getLocalizedDayJs(timeEntry.end!);
extendedProps: {
timeEntry, return {
project, id: timeEntry.id,
client, start: startTime.format(),
task, end: endTime.format(),
duration, title,
}, 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 props.timeEntries.forEach((entry) => {
.filter((entry) => entry.end !== null) const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
.forEach((entry) => { let duration: number;
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
const duration = getDayJsInstance()(entry.end!).diff( if (entry.end !== null) {
// Completed entry
duration = getDayJsInstance()(entry.end).diff(
getDayJsInstance()(entry.start), getDayJsInstance()(entry.start),
'minutes' 'minutes'
); );
totals[date] = (totals[date] || 0) + duration; } else {
}); // Running entry - use current time
duration = currentTime.value.diff(getDayJsInstance()(entry.start), 'minutes');
}
totals[date] = (totals[date] || 0) + duration;
});
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 }) {
@@ -181,6 +219,10 @@ 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;
} }
@@ -194,11 +236,13 @@ 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;
@@ -215,20 +259,25 @@ 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(),
end: getDayJsInstance()(arg.event.end.toISOString()) // Preserve null end for running entries
.utc() end: ext.isRunning
.tz(getUserTimezone(), true) ? null
.utc() : getDayJsInstance()(arg.event.end.toISOString())
.format(), .utc()
.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], plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
initialView: 'timeGridWeek', initialView: 'timeGridWeek',
headerToolbar: { headerToolbar: {
left: 'prev,next today', left: 'prev,next today',
@@ -241,10 +290,11 @@ 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:15:00', snapDuration: '00:01: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,
@@ -259,6 +309,7 @@ const calendarOptions = computed(() => ({
datesSet: emitDatesChange, datesSet: emitDatesChange,
events: events.value, events: events.value,
activityPeriods: props.activityPeriods || [],
})); }));
watch(showCreateTimeEntryModal, (value) => { watch(showCreateTimeEntryModal, (value) => {
@@ -277,6 +328,60 @@ 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>
@@ -295,6 +400,8 @@ watch(showEditTimeEntryModal, (value) => {
: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"
@@ -314,7 +421,9 @@ watch(showEditTimeEntryModal, (value) => {
: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
@@ -367,11 +476,11 @@ watch(showEditTimeEntryModal, (value) => {
} }
.fullcalendar :deep(.fc-timegrid-slot-label) { .fullcalendar :deep(.fc-timegrid-slot-label) {
background-color: var(--theme-color-default-background); background-color: var(--background);
} }
.fullcalendar :deep(.fc-toolbar) { .fullcalendar :deep(.fc-toolbar) {
background-color: var(--theme-color-default-background); background-color: var(--background);
padding: 0.5rem; padding: 0.5rem;
margin-bottom: 0; margin-bottom: 0;
} }
@@ -452,8 +561,8 @@ watch(showEditTimeEntryModal, (value) => {
} }
.fullcalendar :deep(.fc-event) { .fullcalendar :deep(.fc-event) {
border-radius: var(--radius); border-radius: calc(var(--radius) - 4px);
padding: 0.45rem 0.25rem; padding: 0;
font-size: 0.75rem; font-size: 0.75rem;
cursor: pointer; cursor: pointer;
box-shadow: var(--theme-shadow-card); box-shadow: var(--theme-shadow-card);
@@ -515,7 +624,7 @@ watch(showEditTimeEntryModal, (value) => {
} }
.fullcalendar :deep(.fc-highlight) { .fullcalendar :deep(.fc-highlight) {
background-color: var(--theme-color-default-background); background-color: var(--primary);
} }
.fullcalendar :deep(.fc-select-mirror) { .fullcalendar :deep(.fc-select-mirror) {
@@ -533,7 +642,7 @@ watch(showEditTimeEntryModal, (value) => {
} }
.fullcalendar :deep(.fc-timegrid-body) { .fullcalendar :deep(.fc-timegrid-body) {
background-color: var(--theme-color-default-background); background-color: var(--background);
} }
.fullcalendar :deep(.fc-timegrid-col) { .fullcalendar :deep(.fc-timegrid-col) {
@@ -600,4 +709,44 @@ watch(showEditTimeEntryModal, (value) => {
.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) {
transition: opacity 0.2s ease;
}
.fullcalendar :deep(.activity-status-box.idle) {
background-color: rgba(156, 163, 175, 0.1) !important;
}
.fullcalendar :deep(.activity-status-box.idle):hover {
background-color: rgba(156, 163, 175, 0.5) !important;
}
.fullcalendar :deep(.activity-status-box.active) {
background-color: rgba(34, 197, 94, 0.3) !important;
}
.fullcalendar :deep(.activity-status-box.active):hover {
background-color: rgba(34, 197, 94, 1) !important;
}
/* Add left margin to events only on days with activity status data */
.fullcalendar :deep(.has-activity-status .fc-timegrid-event-harness) {
margin-left: 15px !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>

View File

@@ -0,0 +1,241 @@
import { createPlugin, type PluginDef } from '@fullcalendar/core';
import { computePosition, flip, shift, offset } from '@floating-ui/dom';
export interface ActivityPeriod {
start: string;
end: string;
isIdle: boolean;
}
export interface ActivityStatusPluginOptions {
activityPeriods?: ActivityPeriod[];
}
/**
* Creates and manages a tooltip element for activity status boxes
*/
function createTooltip(): HTMLElement {
const tooltip = document.createElement('div');
tooltip.className =
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';
tooltip.style.position = 'fixed';
tooltip.style.pointerEvents = 'none';
tooltip.style.opacity = '0';
tooltip.style.whiteSpace = 'nowrap';
tooltip.style.transform = 'scale(0.95)';
tooltip.style.transition = 'opacity 150ms, transform 150ms';
document.body.appendChild(tooltip);
return tooltip;
}
/**
* Shows tooltip for an activity status box
*/
function showTooltip(box: HTMLElement, tooltip: HTMLElement, text: string) {
tooltip.textContent = text;
tooltip.style.opacity = '1';
tooltip.style.transform = 'scale(1)';
const updatePosition = () => {
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`;
});
};
updatePosition();
}
/**
* Hides the tooltip
*/
function hideTooltip(tooltip: HTMLElement) {
tooltip.style.opacity = '0';
tooltip.style.transform = 'scale(0.95)';
}
/**
* Renders activity status boxes in the calendar time grid
*/
export function renderActivityStatusBoxes(
calendarEl: HTMLElement,
activityPeriods: ActivityPeriod[]
) {
if (!calendarEl) return;
// Clean up existing activity boxes and markers first
const existingBoxes = calendarEl.querySelectorAll('.activity-status-box');
existingBoxes.forEach((box) => box.remove());
// Clean up existing tooltips
const existingTooltips = document.querySelectorAll('.activity-status-tooltip');
existingTooltips.forEach((tooltip) => tooltip.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) {
console.log('No timegrid found');
return;
}
const lanes = timeGrid.querySelectorAll('.fc-timegrid-col');
if (lanes.length === 0) {
console.log('No lanes found');
return;
}
console.log(
'Rendering activity status boxes, lanes:',
lanes.length,
'periods:',
activityPeriods.length
);
// Create a single tooltip instance to be reused
const tooltip = createTooltip();
lanes.forEach((lane: Element, dayIndex: number) => {
// Get the date for this lane from the data attribute
const laneEl = lane as HTMLElement;
const dateStr = laneEl.getAttribute('data-date');
if (!dateStr) {
console.log('No date attribute found for lane', dayIndex);
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 the position and height of the idle box
const { top, height } = calculateBoxPosition(
calendarEl,
periodStart > laneDateStart ? periodStart : laneDateStart,
periodEnd < laneDateEnd ? periodEnd : laneDateEnd
);
if (height <= 0) return;
hasActivityStatusForThisDay = true;
// Create and append the activity status box
const box = document.createElement('div');
box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;
box.style.position = 'absolute';
box.style.top = `${top}px`;
box.style.height = `${height}px`;
box.style.width = '8px';
box.style.left = '4px';
box.style.right = '4px';
box.style.zIndex = '10';
box.style.cursor = 'default';
// Calculate duration in minutes
const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;
const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;
const durationMs = actualEnd.getTime() - actualStart.getTime();
const durationMinutes = Math.round(durationMs / 60000);
// Format duration
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
const durationText = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
// Add tooltip text based on status
const status = period.isIdle ? 'Idling' : 'Active';
const tooltipText = `${status} (${durationText})`;
// Add hover event listeners for tooltip
box.addEventListener('mouseenter', () => {
showTooltip(box, tooltip, tooltipText);
});
box.addEventListener('mouseleave', () => {
hideTooltip(tooltip);
});
// Position relative to the lane
const laneFrame = lane.querySelector('.fc-timegrid-col-frame');
if (laneFrame) {
laneFrame.appendChild(box);
} else {
console.log('No lane frame found');
}
});
// Mark this lane as having activity status if any periods were rendered
if (hasActivityStatusForThisDay) {
laneEl.classList.add('has-activity-status');
}
});
}
/**
* Calculates the pixel position and height for an activity status box
*/
function calculateBoxPosition(
calendarEl: HTMLElement,
startTime: Date,
endTime: Date
): { top: number; height: number } {
// Get the slot duration and slot height
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
if (slotsEl.length === 0) {
console.log('No slots found');
return { top: 0, height: 0 };
}
// Calculate slot height (assuming all slots are equal height)
const firstSlot = slotsEl[0] as HTMLElement;
const slotHeight = firstSlot.offsetHeight;
// Each slot is 15 minutes by default (configured in TimeEntryCalendar)
const slotDurationMinutes = 15;
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 };
}
/**
* 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;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button'; import Button from '../Buttons/Button.vue';
import { RangeCalendar } from '@/Components/ui/range-calendar'; import { RangeCalendar } from '@/Components/ui/range-calendar';
import { CalendarDate } from '@internationalized/date'; import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next'; import { CalendarIcon } from 'lucide-vue-next';

View File

@@ -27,11 +27,19 @@ function updateTime(event: Event) {
if (newValue.split(':').length === 2) { if (newValue.split(':').length === 2) {
const [hours, minutes] = newValue.split(':'); const [hours, minutes] = newValue.split(':');
if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) { if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) {
model.value = getLocalizedDayJs(model.value) const currentTime = getLocalizedDayJs(model.value);
.set('hours', Math.min(parseInt(hours), 23)) const newHours = Math.min(parseInt(hours), 23);
.set('minutes', Math.min(parseInt(minutes), 59)) const newMinutes = Math.min(parseInt(minutes), 59);
.format();
emit('changed', model.value); // Only update if hours or minutes are different
if (currentTime.hour() !== newHours || currentTime.minute() !== newMinutes) {
model.value = currentTime
.set('hours', newHours)
.set('minutes', newMinutes)
.set('seconds', 0)
.format();
emit('changed', model.value);
}
} }
} }
// check if input is only numbers // check if input is only numbers
@@ -42,6 +50,7 @@ function updateTime(event: Event) {
model.value = getLocalizedDayJs(model.value) model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23)) .set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59)) .set('minutes', Math.min(parseInt(minutes), 59))
.set('seconds', 0)
.format(); .format();
emit('changed', model.value); emit('changed', model.value);
} else if (newValue.length === 3) { } else if (newValue.length === 3) {
@@ -50,6 +59,7 @@ function updateTime(event: Event) {
model.value = getLocalizedDayJs(model.value) model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23)) .set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59)) .set('minutes', Math.min(parseInt(minutes), 59))
.set('seconds', 0)
.format(); .format();
emit('changed', model.value); emit('changed', model.value);
} else if (newValue.length === 2) { } else if (newValue.length === 2) {
@@ -57,6 +67,7 @@ function updateTime(event: Event) {
model.value = getLocalizedDayJs(model.value) model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(newValue), 23)) .set('hours', Math.min(parseInt(newValue), 23))
.set('minutes', 0) .set('minutes', 0)
.set('seconds', 0)
.format(); .format();
emit('changed', model.value); emit('changed', model.value);
} else if (newValue.length === 1) { } else if (newValue.length === 1) {
@@ -64,6 +75,7 @@ function updateTime(event: Event) {
model.value = getLocalizedDayJs(model.value) model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(newValue), 23)) .set('hours', Math.min(parseInt(newValue), 23))
.set('minutes', 0) .set('minutes', 0)
.set('seconds', 0)
.format(); .format();
emit('changed', model.value); emit('changed', model.value);
} }

View File

@@ -4,7 +4,7 @@ import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time'; import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue'; import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
import { Button } from '@/Components/ui/button'; import Button from '../Buttons/Button.vue';
const props = defineProps<{ const props = defineProps<{
start: string; start: string;

View File

@@ -93,7 +93,7 @@ function onSelectChange(checked: boolean) {
class="border-b border-default-background-separator bg-row-background min-w-0 transition" class="border-b border-default-background-separator bg-row-background min-w-0 transition"
data-testid="time_entry_row"> data-testid="time_entry_row">
<MainContainer class="min-w-0"> <MainContainer class="min-w-0">
<div class="sm:flex py-2 items-center min-w-0 justify-between group"> <div class="@xl:flex py-2 items-center min-w-0 justify-between group">
<div class="flex space-x-3 items-center min-w-0"> <div class="flex space-x-3 items-center min-w-0">
<Checkbox <Checkbox
:checked=" :checked="
@@ -125,7 +125,7 @@ function onSelectChange(checked: boolean) {
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown> @changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
</div> </div>
</div> </div>
<div class="flex items-center font-medium lg:space-x-2"> <div class="flex items-center font-medium space-x-1 @lg:space-x-2">
<TimeEntryRowTagDropdown <TimeEntryRowTagDropdown
:create-tag :create-tag
:tags="tags" :tags="tags"
@@ -142,8 +142,8 @@ function onSelectChange(checked: boolean) {
twMerge( twMerge(
'text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary', 'text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary',
organization?.time_format === '12-hours' organization?.time_format === '12-hours'
? 'w-[170px]' ? 'w-[160px]'
: 'w-[120px]' : 'w-[100px]'
) )
" "
@click="expanded = !expanded"> @click="expanded = !expanded">
@@ -157,7 +157,7 @@ function onSelectChange(checked: boolean) {
</button> </button>
</div> </div>
<button <button
class="text-text-primary min-w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary" class="text-text-primary !mr-2 min-w-[80px] px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
@click="expanded = !expanded"> @click="expanded = !expanded">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(

View File

@@ -15,8 +15,6 @@ import type {
Client, Client,
CreateTimeEntryBody, CreateTimeEntryBody,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { getOrganizationCurrencyString } from '@/utils/money';
import { canCreateProjects } from '@/utils/permissions';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { Badge } from '@/packages/ui/src'; import { Badge } from '@/packages/ui/src';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
@@ -43,6 +41,8 @@ const props = defineProps<{
clients: Client[]; clients: Client[];
start?: string; start?: string;
end?: string; end?: string;
currency: string;
canCreateProject: boolean;
}>(); }>();
const description = ref<HTMLInputElement | null>(null); const description = ref<HTMLInputElement | null>(null);
@@ -61,8 +61,8 @@ const timeEntryDefaultValues = {
task_id: null, task_id: null,
tags: [], tags: [],
billable: false, billable: false,
start: getDayJsInstance().utc().subtract(1, 'h').format(), start: getDayJsInstance().utc().subtract(1, 'h').second(0).format(),
end: getDayJsInstance().utc().format(), end: getDayJsInstance().utc().second(0).format(),
}; };
const timeEntry = ref({ const timeEntry = ref({
@@ -167,8 +167,8 @@ type BillableOption = {
:clients :clients
:create-project :create-project
:create-client :create-client
:can-create-project="canCreateProjects()" :can-create-project
:currency="getOrganizationCurrencyString()" :currency
size="xlarge" size="xlarge"
class="bg-input-background" class="bg-input-background"
:projects="projects" :projects="projects"

View File

@@ -15,8 +15,6 @@ import type {
Client, Client,
TimeEntry, TimeEntry,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { getOrganizationCurrencyString } from '@/utils/money';
import { canCreateProjects } from '@/utils/permissions';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { Badge } from '@/packages/ui/src'; import { Badge } from '@/packages/ui/src';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
@@ -44,6 +42,8 @@ const props = defineProps<{
projects: Project[]; projects: Project[];
tasks: Task[]; tasks: Task[];
clients: Client[]; clients: Client[];
currency: string;
canCreateProject: boolean;
}>(); }>();
const description = ref<HTMLInputElement | null>(null); const description = ref<HTMLInputElement | null>(null);
@@ -163,8 +163,8 @@ type BillableOption = {
:clients :clients
:create-project :create-project
:create-client :create-client
:can-create-project="canCreateProjects()" :can-create-project="canCreateProject"
:currency="getOrganizationCurrencyString()" :currency="currency"
size="xlarge" size="xlarge"
class="bg-input-background" class="bg-input-background"
:projects="projects" :projects="projects"

View File

@@ -94,6 +94,7 @@ const groupedTimeEntries = computed(() => {
groupedEntriesByDayAndType[dailyEntriesKey] = newDailyEntries; groupedEntriesByDayAndType[dailyEntriesKey] = newDailyEntries;
} }
return groupedEntriesByDayAndType; return groupedEntriesByDayAndType;
}); });
@@ -137,79 +138,81 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
</script> </script>
<template> <template>
<div v-for="(value, key) in groupedTimeEntries" :key="key"> <div class="@container">
<TimeEntryRowHeading <div v-for="(value, key) in groupedTimeEntries" :key="key">
:date="key" <TimeEntryRowHeading
:duration="sumDuration(value)" :date="String(key)"
:checked=" :duration="sumDuration(value)"
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry)) :checked="
" value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
@select-all="selectAllTimeEntries(value)"
@unselect-all="unselectAllTimeEntries(value)"></TimeEntryRowHeading>
<template v-for="entry in value" :key="entry.id">
<TimeEntryAggregateRow
v-if="'timeEntries' in entry && entry.timeEntries.length > 1"
:create-project
:can-create-project
:enable-estimated-time
:selected-time-entries="selectedTimeEntries"
:create-client
:projects="projects"
:tasks="tasks"
:tags="tags"
:clients
:on-start-stop-click="startTimeEntryFromExisting"
:duplicate-time-entry="createTimeEntry"
:update-time-entries
:update-time-entry
:delete-time-entries
:create-tag
:currency="currency"
:time-entry="entry"
@selected="
(timeEntries: TimeEntry[]) => {
selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
}
" "
@unselected=" @select-all="selectAllTimeEntries(value)"
(timeEntriesToUnselect: TimeEntry[]) => { @unselect-all="unselectAllTimeEntries(value)"></TimeEntryRowHeading>
<template v-for="entry in value" :key="entry.id">
<TimeEntryAggregateRow
v-if="'timeEntries' in entry && entry.timeEntries.length > 1"
:create-project
:can-create-project
:enable-estimated-time
:selected-time-entries="selectedTimeEntries"
:create-client
:projects="projects"
:tasks="tasks"
:tags="tags"
:clients
:on-start-stop-click="startTimeEntryFromExisting"
:duplicate-time-entry="createTimeEntry"
:update-time-entries
:update-time-entry
:delete-time-entries
:create-tag
:currency="currency"
:time-entry="entry"
@selected="
(timeEntries: TimeEntry[]) => {
selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
}
"
@unselected="
(timeEntriesToUnselect: TimeEntry[]) => {
selectedTimeEntries = selectedTimeEntries.filter(
(item: TimeEntry) =>
!timeEntriesToUnselect.find(
(filterEntry: TimeEntry) => filterEntry.id === item.id
)
);
}
"></TimeEntryAggregateRow>
<TimeEntryRow
v-else
:create-client
:enable-estimated-time
:can-create-project
:create-project
:projects="projects"
:selected="
!!selectedTimeEntries.find(
(filterEntry: TimeEntry) => filterEntry.id === entry.id
)
"
:tasks="tasks"
:tags="tags"
:clients
:create-tag
:update-time-entry
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
:delete-time-entry="() => deleteTimeEntries([entry])"
:duplicate-time-entry="() => createTimeEntry(entry)"
:currency="currency"
:time-entry="entry.timeEntries[0]"
@selected="selectedTimeEntries.push(entry)"
@unselected="
selectedTimeEntries = selectedTimeEntries.filter( selectedTimeEntries = selectedTimeEntries.filter(
(item: TimeEntry) => (item: TimeEntry) => item.id !== entry.id
!timeEntriesToUnselect.find( )
(filterEntry: TimeEntry) => filterEntry.id === item.id "></TimeEntryRow>
) </template>
); </div>
}
"></TimeEntryAggregateRow>
<TimeEntryRow
v-else
:create-client
:enable-estimated-time
:can-create-project
:create-project
:projects="projects"
:selected="
!!selectedTimeEntries.find(
(filterEntry: TimeEntry) => filterEntry.id === entry.id
)
"
:tasks="tasks"
:tags="tags"
:clients
:create-tag
:update-time-entry
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
:delete-time-entry="() => deleteTimeEntries([entry])"
:duplicate-time-entry="() => createTimeEntry(entry)"
:currency="currency"
:time-entry="entry.timeEntries[0]"
@selected="selectedTimeEntries.push(entry)"
@unselected="
selectedTimeEntries = selectedTimeEntries.filter(
(item: TimeEntry) => item.id !== entry.id
)
"></TimeEntryRow>
</template>
</div> </div>
</template> </template>

View File

@@ -35,11 +35,11 @@ const organization = inject<ComputedRef<Organization>>('organization');
data-testid="time_entry_range_selector" data-testid="time_entry_range_selector"
:class=" :class="
twMerge( twMerge(
'text-text-secondary px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary', 'text-text-secondary px-1 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
showDate showDate
? 'text-xs py-1.5 font-semibold' ? 'text-xs py-1.5 font-semibold'
: 'text-sm py-1.5 font-medium', : 'text-sm py-1.5 font-medium',
organization?.time_format === '12-hours' ? 'w-[170px]' : 'w-[120px]', organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]',
open && 'border-card-border bg-card-background' open && 'border-card-border bg-card-background'
) )
"> ">

View File

@@ -112,7 +112,7 @@ async function handleDeleteTimeEntry() {
class="border-b border-default-background-separator transition min-w-0 bg-row-background" class="border-b border-default-background-separator transition min-w-0 bg-row-background"
data-testid="time_entry_row"> data-testid="time_entry_row">
<MainContainer class="min-w-0"> <MainContainer class="min-w-0">
<div class="sm:flex py-2 min-w-0 items-center justify-between group"> <div class="@xl:flex py-2 min-w-0 items-center justify-between group">
<div class="flex items-center min-w-0"> <div class="flex items-center min-w-0">
<Checkbox :checked="selected" @update:checked="onSelectChange" /> <Checkbox :checked="selected" @update:checked="onSelectChange" />
<div v-if="indent === true" class="w-10 h-7"></div> <div v-if="indent === true" class="w-10 h-7"></div>
@@ -134,7 +134,7 @@ async function handleDeleteTimeEntry() {
:task="timeEntry.task_id" :task="timeEntry.task_id"
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown> @changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
</div> </div>
<div class="flex items-center font-medium space-x-1 lg:space-x-2"> <div class="flex items-center font-medium space-x-1 @lg:space-x-2">
<div v-if="showMember && members" class="text-sm px-2"> <div v-if="showMember && members" class="text-sm px-2">
{{ memberName }} {{ memberName }}
</div> </div>
@@ -186,7 +186,9 @@ async function handleDeleteTimeEntry() {
:tags="tags" :tags="tags"
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
:clients="clients" /> :clients="clients"
:currency="currency"
:can-create-project="canCreateProject" />
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -77,7 +77,7 @@ function selectInput(event: Event) {
v-model="currentTime" v-model="currentTime"
data-testid="time_entry_duration_input" data-testid="time_entry_duration_input"
name="Duration" name="Duration"
class="text-text-primary w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring" class="text-text-primary w-[80px] !mr-2 px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
@focus="selectInput" @focus="selectInput"
@keydown.tab="open = false" @keydown.tab="open = false"
@blur="updateTimerAndStartLiveTimerUpdate" @blur="updateTimerAndStartLiveTimerUpdate"

View File

@@ -32,13 +32,13 @@ function selectUnselectAll(value: boolean) {
<template> <template>
<div <div
class="bg-row-heading-background border-t border-b border-row-heading-border py-1 text-xs sm:text-sm"> class="bg-row-heading-background border-t border-b border-row-heading-border py-1 text-xs @sm:text-sm">
<MainContainer> <MainContainer>
<div class="flex group justify-between items-center"> <div class="flex group justify-between items-center">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="w-5"> <div class="w-5">
<svg <svg
class="w-3 sm:w-4 text-icon-default group-hover:hidden block" class="w-3 @sm:w-4 text-icon-default group-hover:hidden block"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
<g fill="none"> <g fill="none">
@@ -61,7 +61,7 @@ function selectUnselectAll(value: boolean) {
{{ formatDate(date, organization?.date_format) }} {{ formatDate(date, organization?.date_format) }}
</span> </span>
</div> </div>
<div class="text-text-secondary pr-[90px] lg:pr-[92px]"> <div class="text-text-secondary pr-[87px] @lg:pr-[92px]">
<span class="font-medium"> <span class="font-medium">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(

View File

@@ -15,8 +15,6 @@ import type {
} from '@/packages/api/src'; } from '@/packages/api/src';
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { storeToRefs } from 'pinia';
import { useFocus } from '@vueuse/core'; import { useFocus } from '@vueuse/core';
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue'; import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue'; import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
@@ -34,6 +32,7 @@ const props = defineProps<{
tasks: Task[]; tasks: Task[];
tags: Tag[]; tags: Tag[];
clients: Client[]; clients: Client[];
timeEntries: TimeEntry[];
createTag: (name: string) => Promise<Tag | undefined>; createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>; createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
@@ -131,10 +130,9 @@ function updateTimeEntryDescription() {
} }
} }
const { timeEntries } = storeToRefs(useTimeEntriesStore());
const filteredRecentlyTrackedTimeEntries = computed(() => { const filteredRecentlyTrackedTimeEntries = computed(() => {
// do not include running time entries // do not include running time entries
const finishedTimeEntries = timeEntries.value.filter((item) => item.end !== null); const finishedTimeEntries = props.timeEntries.filter((item) => item.end !== null);
// filter out duplicates based on description, task, project, tags and billable // filter out duplicates based on description, task, project, tags and billable
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => { const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {

View File

@@ -10,8 +10,12 @@ import * as color from './utils/color';
import * as random from './utils/random'; import * as random from './utils/random';
import * as time from './utils/time'; import * as time from './utils/time';
export { cn } from './utils/cn';
export { buttonVariants, type ButtonVariants } from './Buttons/index';
import PrimaryButton from './Buttons/PrimaryButton.vue'; import PrimaryButton from './Buttons/PrimaryButton.vue';
import SecondaryButton from './Buttons/SecondaryButton.vue'; import SecondaryButton from './Buttons/SecondaryButton.vue';
import Button from './Buttons/Button.vue';
import TimeTrackerStartStop from './TimeTrackerStartStop.vue'; import TimeTrackerStartStop from './TimeTrackerStartStop.vue';
import ProjectBadge from './Project/ProjectBadge.vue'; import ProjectBadge from './Project/ProjectBadge.vue';
import LoadingSpinner from './LoadingSpinner.vue'; import LoadingSpinner from './LoadingSpinner.vue';
@@ -20,6 +24,7 @@ import TextInput from './Input/TextInput.vue';
import InputLabel from './Input/InputLabel.vue'; import InputLabel from './Input/InputLabel.vue';
import TimeTrackerRunningInDifferentOrganizationOverlay from './TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue'; import TimeTrackerRunningInDifferentOrganizationOverlay from './TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
import TimeTrackerControls from './TimeTracker/TimeTrackerControls.vue'; import TimeTrackerControls from './TimeTracker/TimeTrackerControls.vue';
import TimeTrackerMoreOptionsDropdown from './TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
import CardTitle from './CardTitle.vue'; import CardTitle from './CardTitle.vue';
import SelectDropdown from './Input/SelectDropdown.vue'; import SelectDropdown from './Input/SelectDropdown.vue';
import Badge from './Badge.vue'; import Badge from './Badge.vue';
@@ -32,12 +37,15 @@ import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue'; import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue'; import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue'; import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
export { export {
money, money,
color, color,
random, random,
time, time,
Button,
PrimaryButton, PrimaryButton,
SecondaryButton, SecondaryButton,
TimeTrackerStartStop, TimeTrackerStartStop,
@@ -48,6 +56,7 @@ export {
InputLabel, InputLabel,
TimeTrackerRunningInDifferentOrganizationOverlay, TimeTrackerRunningInDifferentOrganizationOverlay,
TimeTrackerControls, TimeTrackerControls,
TimeTrackerMoreOptionsDropdown,
CardTitle, CardTitle,
SelectDropdown, SelectDropdown,
Badge, Badge,
@@ -60,4 +69,8 @@ export {
FullCalendarEventContent, FullCalendarEventContent,
FullCalendarDayHeader, FullCalendarDayHeader,
TimeEntryCalendar, TimeEntryCalendar,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
}; };

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui';
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui';
const props = defineProps<TooltipRootProps>();
const emits = defineEmits<TooltipRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<TooltipRoot v-bind="forwarded">
<slot />
</TooltipRoot>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui';
import { cn } from '@/lib/utils';
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(
defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),
{
sideOffset: 4,
}
);
const emits = defineEmits<TooltipContentEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<TooltipPortal>
<TooltipContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
">
<slot />
</TooltipContent>
</TooltipPortal>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui';
import { TooltipProvider } from 'reka-ui';
const props = defineProps<TooltipProviderProps>();
</script>
<template>
<TooltipProvider v-bind="props">
<slot />
</TooltipProvider>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui';
import { TooltipTrigger } from 'reka-ui';
const props = defineProps<TooltipTriggerProps>();
</script>
<template>
<TooltipTrigger v-bind="props">
<slot />
</TooltipTrigger>
</template>

View File

@@ -0,0 +1,4 @@
export { default as Tooltip } from './Tooltip.vue';
export { default as TooltipContent } from './TooltipContent.vue';
export { default as TooltipProvider } from './TooltipProvider.vue';
export { default as TooltipTrigger } from './TooltipTrigger.vue';

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs.filter(Boolean)));
}

View File

@@ -0,0 +1,240 @@
/**
* Shared styles for solidtime
* This CSS file contains all the shared theme variables and base styles
* used by both the main solidtime app and the desktop app.
*
* Font-face declarations are intentionally omitted here as they differ between apps:
* - Main app uses 'Inter'
* - Desktop app uses 'Outfit'
* Each app should include their own font-face declarations.
*/
@tailwind base;
@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;
--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);
}
@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(--color-bg-primary);
--primary-foreground: var(--color-text-primary);
--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(--color-bg-primary);
--primary-foreground: var(--color-text-primary);
--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;
}
}

View File

@@ -0,0 +1,131 @@
/**
* Shared Tailwind theme configuration for solidtime
* This configuration is used by both the main solidtime app and the desktop app
*
* Note: fontFamily is intentionally omitted here as it differs between apps:
* - Main app uses 'Inter'
* - Desktop app uses 'Outfit'
* Each app should override the fontFamily in their own config.
*/
export const solidtimeTheme = {
boxShadow: {
card: 'var(--theme-shadow-card)',
dropdown: 'var(--theme-shadow-dropdown)',
},
containers: {
'2xs': '16rem',
},
fontSize: {
'2xs': ['0.625rem', { lineHeight: '0.75rem' }],
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.8125rem', { lineHeight: '1.125rem' }],
base: ['0.875rem', { lineHeight: '1.25rem' }],
lg: ['1rem', { lineHeight: '1.5rem' }],
xl: ['1.125rem', { lineHeight: '1.75rem' }],
'2xl': ['1.25rem', { lineHeight: '1.75rem' }],
'3xl': ['1.5rem', { lineHeight: '2rem' }],
'4xl': ['1.75rem', { lineHeight: '2.25rem' }],
'5xl': ['2rem', { lineHeight: '1' }],
'6xl': ['2.25rem', { lineHeight: '1' }],
'7xl': ['2.5rem', { lineHeight: '1' }],
'8xl': ['3rem', { lineHeight: '1' }],
'9xl': ['3.5rem', { lineHeight: '1' }],
},
colors: {
ring: 'var(--ring)',
primary: {
DEFAULT: 'var(--primary)',
foreground: 'var(--primary-foreground)',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
tertiary: 'var(--color-bg-tertiary)',
quaternary: 'var(--color-bg-quaternary)',
background: 'var(--background)',
'text-primary': 'var(--color-text-primary)',
'text-secondary': 'var(--color-text-secondary)',
'text-tertiary': 'var(--color-text-tertiary)',
'text-quaternary': 'var(--color-text-quaternary)',
'border-primary': 'var(--color-border-primary)',
'border-secondary': 'var(--color-border-secondary)',
'border-tertiary': 'var(--color-border-tertiary)',
'default-background': 'var(--theme-color-default-background)',
'default-background-separator': 'var(--theme-color-default-background-separator)',
'row-background': 'var(--theme-color-row-background)',
'card-background': 'var(--theme-color-card-background)',
'card-background-active': 'var(--theme-color-card-background-active)',
'card-background-separator': 'var(--theme-color-card-background-separator)',
'card-border': 'var(--theme-color-card-border)',
'card-border-active': 'var(--theme-color-card-border-active)',
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
'tab-background': 'var(--theme-color-tab-background)',
'tab-background-active': 'var(--theme-color-tab-background-active)',
'tab-border': 'var(--theme-color-tab-border)',
'icon-default': 'var(--theme-color-icon-default)',
'icon-active': 'var(--theme-color-icon-active)',
'menu-active': 'var(--theme-color-menu-active)',
'input-border': 'var(--theme-color-input-border)',
'input-border-active': 'var(--color-input-border-active)',
'input-background': 'var(--theme-color-input-background)',
'button-secondary-background': 'var(--theme-button-secondary-background)',
'button-secondary-background-hover': 'var(--theme-button-secondary-background-active)',
'button-secondary-border': 'var(--theme-color-card-border)',
'row-separator': 'var(--theme-color-row-separator-background)',
'row-heading-background': 'var(--theme-color-row-heading-background)',
'row-heading-border': 'var(--theme-color-row-heading-border)',
accent: {
'50': 'rgba(var(--color-accent-50), <alpha-value>)',
'100': 'rgba(var(--color-accent-100), <alpha-value>)',
'200': 'rgba(var(--color-accent-200), <alpha-value>)',
'300': 'rgba(var(--color-accent-300), <alpha-value>)',
'400': 'rgba(var(--color-accent-400), <alpha-value>)',
'500': 'rgba(var(--color-accent-500), <alpha-value>)',
'600': 'rgba(var(--color-accent-600), <alpha-value>)',
'700': 'rgba(var(--color-accent-700), <alpha-value>)',
'800': 'rgba(var(--color-accent-800), <alpha-value>)',
'900': 'rgba(var(--color-accent-900), <alpha-value>)',
'950': 'rgba(var(--color-accent-950), <alpha-value>)',
DEFAULT: 'var(--color-accent-default)',
foreground: 'var(--color-accent-foreground)',
},
'button-primary-background': 'var(--theme-color-button-primary-background)',
'button-primary-background-hover': 'var(--theme-color-button-primary-background-hover)',
'button-primary-border': 'var(--theme-color-button-primary-border)',
'button-primary-text': 'var(--theme-color-button-primary-text)',
'input-select-active': 'var(--theme-color-input-select-active)',
'input-select-active-hover': 'var(--theme-color-input-select-active-hover)',
foreground: 'var(--foreground)',
card: {
DEFAULT: 'var(--card))',
foreground: 'var(--card-foreground))',
},
popover: {
DEFAULT: 'var(--popover)',
foreground: 'var(--popover-foreground)',
border: 'var(--popover-border)',
},
destructive: {
DEFAULT: 'var(--destructive)',
foreground: 'var(--destructive-foreground)',
},
border: 'var(--border)',
input: 'var(--input)',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
};

View File

@@ -81,7 +81,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
} }
return response; return response;
} catch { } catch {
router.get(route('login')); router.get('/login');
} }
} else { } else {
addNotification('error', 'The action failed. Please try again later.'); addNotification('error', 'The action failed. Please try again later.');

View File

@@ -1,225 +1,246 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import { reactive, ref } from 'vue'; import { reactive, ref, type Ref } from 'vue';
import { import { api } from '@/packages/api/src';
api, import type {
type CreateTimeEntryBody, CreateTimeEntryBody,
type TimeEntriesQueryParams, TimeEntriesQueryParams,
type TimeEntry, TimeEntry,
UpdateMultipleTimeEntriesChangeset,
} from '@/packages/api/src'; } from '@/packages/api/src';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src'; import type {} from '@/packages/api/src';
import { useQueryClient } from '@tanstack/vue-query'; import { useQueryClient } from '@tanstack/vue-query';
export const useTimeEntriesStore = defineStore('timeEntries', () => { export const useTimeEntriesStore = defineStore(
const timeEntries = ref<TimeEntry[]>(reactive([])); 'timeEntries',
(): {
timeEntries: Ref<TimeEntry[]>;
fetchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise<void>;
updateTimeEntry: (timeEntry: TimeEntry) => Promise<void>;
createTimeEntry: (timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) => Promise<void>;
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
fetchMoreTimeEntries: () => Promise<void>;
allTimeEntriesLoaded: Ref<boolean>;
updateTimeEntries: (
ids: string[],
changes: UpdateMultipleTimeEntriesChangeset
) => Promise<void>;
deleteTimeEntries: (timeEntries: TimeEntry[]) => Promise<void>;
patchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise<void>;
} => {
const timeEntries = ref<TimeEntry[]>(reactive([]));
const allTimeEntriesLoaded = ref(false); const allTimeEntriesLoaded = ref(false);
const { handleApiRequestNotifications } = useNotificationsStore(); const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
async function patchTimeEntries( async function patchTimeEntries(
queryParams: TimeEntriesQueryParams = { queryParams: TimeEntriesQueryParams = {
only_full_dates: 'true', only_full_dates: 'true',
member_id: getCurrentMembershipId(), member_id: getCurrentMembershipId(),
}
) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
queries: queryParams,
}),
undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data) {
// insert missing time entries
const missingTimeEntries = timeEntriesResponse.data.filter(
(entry) => !timeEntries.value.find((e) => e.id === entry.id)
);
timeEntries.value = [...missingTimeEntries, ...timeEntries.value];
} }
} ) {
} const organizationId = getCurrentOrganizationId();
async function fetchTimeEntries( if (organizationId) {
queryParams: TimeEntriesQueryParams = { const timeEntriesResponse = await handleApiRequestNotifications(
only_full_dates: 'true', () =>
member_id: getCurrentMembershipId(), api.getTimeEntries({
}
) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
queries: queryParams,
}),
undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data) {
timeEntries.value = timeEntriesResponse.data;
}
}
}
async function fetchMoreTimeEntries() {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const latestTimeEntry = timeEntries.value[timeEntries.value.length - 1];
dayjs(latestTimeEntry.start).utc().format('YYYY-MM-DD');
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
queries: {
only_full_dates: 'true',
member_id: getCurrentMembershipId(),
end: dayjs(latestTimeEntry.start).utc().format(),
},
}),
undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data && timeEntriesResponse.data.length > 0) {
timeEntries.value = timeEntries.value.concat(timeEntriesResponse.data);
} else {
allTimeEntriesLoaded.value = true;
}
}
}
async function updateTimeEntries(ids: string[], changes: UpdateMultipleTimeEntriesChangeset) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.updateMultipleTimeEntries(
{
ids: ids,
changes: changes,
},
{
params: { params: {
organization: organizationId, organization: organizationId,
}, },
} queries: queryParams,
), }),
'Time entries updated successfully', undefined,
'Failed to update time entries' 'Failed to fetch time entries'
); );
if (timeEntriesResponse?.data) {
// insert missing time entries
const missingTimeEntries = timeEntriesResponse.data.filter(
(entry) => !timeEntries.value.find((e) => e.id === entry.id)
);
timeEntries.value = [...missingTimeEntries, ...timeEntries.value];
}
}
} }
}
async function updateTimeEntry(timeEntry: TimeEntry) { async function fetchTimeEntries(
const organizationId = getCurrentOrganizationId(); queryParams: TimeEntriesQueryParams = {
if (organizationId) { only_full_dates: 'true',
const response = await handleApiRequestNotifications( member_id: getCurrentMembershipId(),
() => }
api.updateTimeEntry(timeEntry, { ) {
params: { const organizationId = getCurrentOrganizationId();
organization: organizationId,
timeEntry: timeEntry.id, if (organizationId) {
}, const timeEntriesResponse = await handleApiRequestNotifications(
}), () =>
'Time entry updated successfully', api.getTimeEntries({
'Failed to update time entry' params: {
); organization: organizationId,
timeEntries.value = timeEntries.value.map((entry) => },
entry.id === timeEntry.id ? response.data : entry queries: queryParams,
); }),
queryClient.invalidateQueries({ queryKey: ['timeEntry'] }); undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data) {
timeEntries.value = timeEntriesResponse.data;
}
}
} }
}
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) { async function fetchMoreTimeEntries() {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
const memberId = getCurrentMembershipId(); if (organizationId) {
if (organizationId && memberId !== undefined) { const latestTimeEntry = timeEntries.value[timeEntries.value.length - 1];
const newTimeEntry = { dayjs(latestTimeEntry.start).utc().format('YYYY-MM-DD');
...timeEntry,
member_id: memberId, const timeEntriesResponse = await handleApiRequestNotifications(
} as CreateTimeEntryBody; () =>
await handleApiRequestNotifications( api.getTimeEntries({
() => params: {
api.createTimeEntry(newTimeEntry, { organization: organizationId,
params: { },
organization: organizationId, queries: {
}, only_full_dates: 'true',
}), member_id: getCurrentMembershipId(),
'Time entry created successfully', end: dayjs(latestTimeEntry.start).utc().format(),
'Failed to create time entry' },
); }),
await fetchTimeEntries(); undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data && timeEntriesResponse.data.length > 0) {
timeEntries.value = timeEntries.value.concat(timeEntriesResponse.data);
} else {
allTimeEntriesLoaded.value = true;
}
}
} }
}
async function deleteTimeEntry(timeEntryId: string) { async function updateTimeEntries(
const organizationId = getCurrentOrganizationId(); ids: string[],
if (organizationId) { changes: UpdateMultipleTimeEntriesChangeset
await handleApiRequestNotifications( ) {
() => const organizationId = getCurrentOrganizationId();
api.deleteTimeEntry(undefined, { if (organizationId) {
params: { await handleApiRequestNotifications(
organization: organizationId, () =>
timeEntry: timeEntryId, api.updateMultipleTimeEntries(
}, {
}), ids: ids,
'Time entry deleted successfully', changes: changes,
'Failed to delete time entry' },
); {
await fetchTimeEntries(); params: {
organization: organizationId,
},
}
),
'Time entries updated successfully',
'Failed to update time entries'
);
}
} }
}
async function deleteTimeEntries(timeEntries: TimeEntry[]) { async function updateTimeEntry(timeEntry: TimeEntry) {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
const timeEntryIds = timeEntries.map((entry) => entry.id); if (organizationId) {
if (organizationId) { const response = await handleApiRequestNotifications(
await handleApiRequestNotifications( () =>
() => api.updateTimeEntry(timeEntry, {
api.deleteTimeEntries(undefined, { params: {
queries: { organization: organizationId,
ids: timeEntryIds, timeEntry: timeEntry.id,
}, },
params: { }),
organization: organizationId, 'Time entry updated successfully',
}, 'Failed to update time entry'
}), );
'Time entries deleted successfully', timeEntries.value = timeEntries.value.map((entry) =>
'Failed to delete time entries' entry.id === timeEntry.id ? response.data : entry
); );
await fetchTimeEntries(); queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
}
} }
}
return { async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
timeEntries, const organizationId = getCurrentOrganizationId();
fetchTimeEntries, const memberId = getCurrentMembershipId();
updateTimeEntry, if (organizationId && memberId !== undefined) {
createTimeEntry, const newTimeEntry = {
deleteTimeEntry, ...timeEntry,
fetchMoreTimeEntries, member_id: memberId,
allTimeEntriesLoaded, } as CreateTimeEntryBody;
updateTimeEntries, await handleApiRequestNotifications(
deleteTimeEntries, () =>
patchTimeEntries, api.createTimeEntry(newTimeEntry, {
}; params: {
}); organization: organizationId,
},
}),
'Time entry created successfully',
'Failed to create time entry'
);
await fetchTimeEntries();
}
}
async function deleteTimeEntry(timeEntryId: string) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.deleteTimeEntry(undefined, {
params: {
organization: organizationId,
timeEntry: timeEntryId,
},
}),
'Time entry deleted successfully',
'Failed to delete time entry'
);
await fetchTimeEntries();
}
}
async function deleteTimeEntries(timeEntries: TimeEntry[]) {
const organizationId = getCurrentOrganizationId();
const timeEntryIds = timeEntries.map((entry) => entry.id);
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.deleteTimeEntries(undefined, {
queries: {
ids: timeEntryIds,
},
params: {
organization: organizationId,
},
}),
'Time entries deleted successfully',
'Failed to delete time entries'
);
await fetchTimeEntries();
}
}
return {
timeEntries,
fetchTimeEntries,
updateTimeEntry,
createTimeEntry,
deleteTimeEntry,
fetchMoreTimeEntries,
allTimeEntriesLoaded,
updateTimeEntries,
deleteTimeEntries,
patchTimeEntries,
};
}
);

View File

@@ -1,6 +1,7 @@
import defaultTheme from 'tailwindcss/defaultTheme'; import defaultTheme from 'tailwindcss/defaultTheme';
import forms from '@tailwindcss/forms'; import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography'; import typography from '@tailwindcss/typography';
import { solidtimeTheme } from './resources/js/packages/ui/tailwind.theme.js';
/** @type {import("tailwindcss").Config} */ /** @type {import("tailwindcss").Config} */
export default { export default {
@@ -16,130 +17,10 @@ export default {
], ],
theme: { theme: {
extend: { extend: {
boxShadow: { ...solidtimeTheme,
card: 'var(--theme-shadow-card)',
dropdown: 'var(--theme-shadow-dropdown)',
},
containers: {
'2xs': '16rem',
},
fontFamily: { fontFamily: {
sans: ['Inter', ...defaultTheme.fontFamily.sans], sans: ['Inter', ...defaultTheme.fontFamily.sans],
}, },
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.8125rem', { lineHeight: '1.125rem' }],
base: ['0.875rem', { lineHeight: '1.25rem' }],
lg: ['1rem', { lineHeight: '1.5rem' }],
xl: ['1.125rem', { lineHeight: '1.75rem' }],
'2xl': ['1.25rem', { lineHeight: '1.75rem' }],
'3xl': ['1.5rem', { lineHeight: '2rem' }],
'4xl': ['1.75rem', { lineHeight: '2.25rem' }],
'5xl': ['2rem', { lineHeight: '1' }],
'6xl': ['2.25rem', { lineHeight: '1' }],
'7xl': ['2.5rem', { lineHeight: '1' }],
'8xl': ['3rem', { lineHeight: '1' }],
'9xl': ['3.5rem', { lineHeight: '1' }],
},
colors: {
ring: 'var(--ring)',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
tertiary: 'var(--color-bg-tertiary)',
quaternary: 'var(--color-bg-quaternary)',
background: 'var(--background)',
'text-primary': 'var(--color-text-primary)',
'text-secondary': 'var(--color-text-secondary)',
'text-tertiary': 'var(--color-text-tertiary)',
'text-quaternary': 'var(--color-text-quaternary)',
'border-primary': 'var(--color-border-primary)',
'border-secondary': 'var(--color-border-secondary)',
'border-tertiary': 'var(--color-border-tertiary)',
'default-background': 'var(--theme-color-default-background)',
'default-background-separator': 'var(--theme-color-default-background-separator)',
'row-background': 'var(--theme-color-row-background)',
'card-background': 'var(--theme-color-card-background)',
'card-background-active': 'var(--theme-color-card-background-active)',
'card-background-separator': 'var(--theme-color-card-background-separator)',
'card-border': 'var(--theme-color-card-border)',
'card-border-active': 'var(--theme-color-card-border-active)',
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
'tab-background': 'var(--theme-color-tab-background)',
'tab-background-active': 'var(--theme-color-tab-background-active)',
'tab-border': 'var(--theme-color-tab-border)',
'icon-default': 'var(--theme-color-icon-default)',
'icon-active': 'var(--theme-color-icon-active)',
'menu-active': 'var(--theme-color-menu-active)',
'input-border': 'var(--theme-color-input-border)',
'input-border-active': 'var(--color-input-border-active)',
'input-background': 'var(--theme-color-input-background)',
'button-secondary-background': 'var(--theme-button-secondary-background)',
'button-secondary-background-hover':
'var(--theme-button-secondary-background-active)',
'button-secondary-border': 'var(--theme-color-card-border)',
'row-separator': 'var(--theme-color-row-separator-background)',
'row-heading-background': 'var(--theme-color-row-heading-background)',
'row-heading-border': 'var(--theme-color-row-heading-border)',
accent: {
'50': 'rgba(var(--color-accent-50), <alpha-value>)',
'100': 'rgba(var(--color-accent-100), <alpha-value>)',
'200': 'rgba(var(--color-accent-200), <alpha-value>)',
'300': 'rgba(var(--color-accent-300), <alpha-value>)',
'400': 'rgba(var(--color-accent-400), <alpha-value>)',
'500': 'rgba(var(--color-accent-500), <alpha-value>)',
'600': 'rgba(var(--color-accent-600), <alpha-value>)',
'700': 'rgba(var(--color-accent-700), <alpha-value>)',
'800': 'rgba(var(--color-accent-800), <alpha-value>)',
'900': 'rgba(var(--color-accent-900), <alpha-value>)',
'950': 'rgba(var(--color-accent-950), <alpha-value>)',
DEFAULT: 'var(--color-accent-default)',
foreground: 'var(--color-accent-foreground)',
},
'button-primary-background': 'var(--theme-color-button-primary-background)',
'button-primary-background-hover':
'var(--theme-color-button-primary-background-hover)',
'button-primary-border': 'var(--theme-color-button-primary-border)',
'button-primary-text': 'var(--theme-color-button-primary-text)',
'input-select-active': 'var(--theme-color-input-select-active)',
'input-select-active-hover': 'var(--theme-color-input-select-active-hover)',
foreground: 'var(--foreground)',
card: {
DEFAULT: 'var(--card))',
foreground: 'var(--card-foreground))',
},
popover: {
DEFAULT: 'var(--popover)',
foreground: 'var(--popover-foreground)',
border: 'var(--popover-border)',
},
destructive: {
DEFAULT: 'var(--destructive)',
foreground: 'var(--destructive-foreground)',
},
border: 'var(--border)',
input: 'var(--input)',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
}, },
}, },

View File

@@ -299,7 +299,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:create', 'tasks:create:all',
]); ]);
$project = Project::factory()->forOrganization($data->organization)->create(); $project = Project::factory()->forOrganization($data->organization)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create([ $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create([
@@ -324,7 +324,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:create', 'tasks:create:all',
]); ]);
$project = Project::factory()->forOrganization($data->organization)->create(); $project = Project::factory()->forOrganization($data->organization)->create();
$otherProject = Project::factory()->forOrganization($data->organization)->create(); $otherProject = Project::factory()->forOrganization($data->organization)->create();
@@ -352,7 +352,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:create', 'tasks:create:all',
]); ]);
$project = Project::factory()->forOrganization($data->organization)->create(); $project = Project::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user); Passport::actingAs($data->user);
@@ -376,7 +376,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:create', 'tasks:create:all',
]); ]);
$project = Project::factory()->forOrganization($data->organization)->create(); $project = Project::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user); Passport::actingAs($data->user);
@@ -408,7 +408,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:create', 'tasks:create:all',
]); ]);
$project = Project::factory()->forOrganization($data->organization)->create(); $project = Project::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user); Passport::actingAs($data->user);
@@ -465,7 +465,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:update', 'tasks:update:all',
]); ]);
$project = Project::factory()->forOrganization($data->organization)->create(); $project = Project::factory()->forOrganization($data->organization)->create();
$name = 'Task 1'; $name = 'Task 1';
@@ -493,7 +493,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:update', 'tasks:update:all',
]); ]);
$project = Project::factory()->forOrganization($data->organization)->create(); $project = Project::factory()->forOrganization($data->organization)->create();
$otherProject = Project::factory()->forOrganization($data->organization)->create(); $otherProject = Project::factory()->forOrganization($data->organization)->create();
@@ -523,7 +523,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:update', 'tasks:update:all',
]); ]);
$task = Task::factory()->forOrganization($data->organization)->create(); $task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user); Passport::actingAs($data->user);
@@ -547,7 +547,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
$now = Carbon::now(); $now = Carbon::now();
$this->travelTo($now); $this->travelTo($now);
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:update', 'tasks:update:all',
]); ]);
$task = Task::factory()->forOrganization($data->organization)->create(); $task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user); Passport::actingAs($data->user);
@@ -570,7 +570,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:update', 'tasks:update:all',
]); ]);
$task = Task::factory()->forOrganization($data->organization)->isDone()->create(); $task = Task::factory()->forOrganization($data->organization)->isDone()->create();
Passport::actingAs($data->user); Passport::actingAs($data->user);
@@ -593,7 +593,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:update', 'tasks:update:all',
]); ]);
$task = Task::factory()->forOrganization($data->organization)->create(); $task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user); Passport::actingAs($data->user);
@@ -621,7 +621,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:update', 'tasks:update:all',
]); ]);
$task = Task::factory()->forOrganization($data->organization)->create(); $task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user); Passport::actingAs($data->user);
@@ -650,7 +650,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:delete', 'tasks:delete:all',
]); ]);
$task = Task::factory()->forOrganization($data->organization)->create(); $task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user); Passport::actingAs($data->user);
@@ -669,7 +669,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:delete', 'tasks:delete:all',
]); ]);
$task = Task::factory()->forOrganization($data->organization)->create(); $task = Task::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()->forMember($data->member)->forTask($task)->forOrganization($data->organization)->create(); TimeEntry::factory()->forMember($data->member)->forTask($task)->forOrganization($data->organization)->create();
@@ -707,10 +707,10 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'tasks:delete', 'tasks:delete:all',
]); ]);
$otherData = $this->createUserWithPermission([ $otherData = $this->createUserWithPermission([
'tasks:delete', 'tasks:delete:all',
]); ]);
$task = Task::factory()->forOrganization($otherData->organization)->create(); $task = Task::factory()->forOrganization($otherData->organization)->create();
Passport::actingAs($data->user); Passport::actingAs($data->user);
@@ -724,4 +724,274 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
'id' => $task->getKey(), 'id' => $task->getKey(),
]); ]);
} }
public function test_store_endpoint_allows_employee_to_create_task_in_public_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
// Assert
$response->assertStatus(201);
$this->assertDatabaseHas(Task::class, [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
'organization_id' => $data->organization->getKey(),
]);
}
public function test_store_endpoint_allows_employee_to_create_task_in_accessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
ProjectMember::factory()->forProject($project)->forMember($data->member)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
// Assert
$response->assertStatus(201);
$this->assertDatabaseHas(Task::class, [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
'organization_id' => $data->organization->getKey(),
]);
}
public function test_store_endpoint_fails_for_employee_creating_task_in_inaccessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseMissing(Task::class, [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
}
public function test_store_endpoint_fails_for_employee_when_employees_can_manage_tasks_is_disabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = false;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseMissing(Task::class, [
'name' => 'Employee Task',
]);
}
public function test_update_endpoint_allows_employee_to_update_task_in_public_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
'name' => 'Updated by Employee',
]);
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
'name' => 'Updated by Employee',
]);
}
public function test_update_endpoint_allows_employee_to_update_task_in_accessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
ProjectMember::factory()->forProject($project)->forMember($data->member)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
'name' => 'Updated by Employee',
]);
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
'name' => 'Updated by Employee',
]);
}
public function test_update_endpoint_fails_for_employee_updating_task_in_inaccessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
$originalName = $task->name;
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
'name' => 'Updated by Employee',
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
'name' => $originalName,
]);
}
public function test_update_endpoint_fails_for_employee_when_employees_can_manage_tasks_is_disabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = false;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
$originalName = $task->name;
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
'name' => 'Updated by Employee',
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
'name' => $originalName,
]);
}
public function test_delete_endpoint_allows_employee_to_delete_task_in_public_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Task::class, [
'id' => $task->getKey(),
]);
}
public function test_delete_endpoint_allows_employee_to_delete_task_in_accessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
ProjectMember::factory()->forProject($project)->forMember($data->member)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Task::class, [
'id' => $task->getKey(),
]);
}
public function test_delete_endpoint_fails_for_employee_deleting_task_in_inaccessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
]);
}
public function test_delete_endpoint_fails_for_employee_when_employees_can_manage_tasks_is_disabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = false;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
]);
}
} }

View File

@@ -124,4 +124,88 @@ class PermissionStoreTest extends TestCase
// Assert // Assert
$this->assertSame(Jetstream::findRole(Role::Employee->value)->permissions, $result); $this->assertSame(Jetstream::findRole(Role::Employee->value)->permissions, $result);
} }
public function test_employee_does_not_have_task_permissions_by_default(): void
{
// Arrange
$organization = Organization::factory()->create([
'employees_can_manage_tasks' => false,
]);
$user = User::factory()->create();
$organization->users()->attach($user, ['role' => Role::Employee->value]);
$permissionStore = new PermissionStore;
$this->actingAs($user);
// Act & Assert
$this->assertFalse($permissionStore->has($organization, 'tasks:create'));
$this->assertFalse($permissionStore->has($organization, 'tasks:update'));
$this->assertFalse($permissionStore->has($organization, 'tasks:delete'));
$this->assertFalse($permissionStore->has($organization, 'tasks:create:all'));
$this->assertFalse($permissionStore->has($organization, 'tasks:update:all'));
$this->assertFalse($permissionStore->has($organization, 'tasks:delete:all'));
}
public function test_employee_has_task_permissions_when_organization_allows_it(): void
{
// Arrange
$organization = Organization::factory()->create([
'employees_can_manage_tasks' => true,
]);
$user = User::factory()->create();
$organization->users()->attach($user, ['role' => Role::Employee->value]);
$permissionStore = new PermissionStore;
$this->actingAs($user);
// Act & Assert
$this->assertTrue($permissionStore->has($organization, 'tasks:create'));
$this->assertTrue($permissionStore->has($organization, 'tasks:update'));
$this->assertTrue($permissionStore->has($organization, 'tasks:delete'));
// Should NOT have the :all permissions
$this->assertFalse($permissionStore->has($organization, 'tasks:create:all'));
$this->assertFalse($permissionStore->has($organization, 'tasks:update:all'));
$this->assertFalse($permissionStore->has($organization, 'tasks:delete:all'));
}
public function test_non_employee_roles_are_not_affected_by_employees_can_manage_tasks_setting(): void
{
// Arrange
$organization = Organization::factory()->create([
'employees_can_manage_tasks' => false,
]);
$admin = User::factory()->create();
$organization->users()->attach($admin, ['role' => Role::Admin->value]);
$permissionStore = new PermissionStore;
$this->actingAs($admin);
// Act & Assert - Admin should have task permissions regardless of the setting
$this->assertTrue($permissionStore->has($organization, 'tasks:create'));
$this->assertTrue($permissionStore->has($organization, 'tasks:update'));
$this->assertTrue($permissionStore->has($organization, 'tasks:delete'));
$this->assertTrue($permissionStore->has($organization, 'tasks:create:all'));
$this->assertTrue($permissionStore->has($organization, 'tasks:update:all'));
$this->assertTrue($permissionStore->has($organization, 'tasks:delete:all'));
}
public function test_get_permissions_includes_task_permissions_for_employee_when_enabled(): void
{
// Arrange
$organization = Organization::factory()->create([
'employees_can_manage_tasks' => true,
]);
$user = User::factory()->create();
$organization->users()->attach($user, ['role' => Role::Employee->value]);
$permissionStore = new PermissionStore;
$this->actingAs($user);
// Act
$result = $permissionStore->getPermissions($organization);
// Assert
$this->assertContains('tasks:create', $result);
$this->assertContains('tasks:update', $result);
$this->assertContains('tasks:delete', $result);
$this->assertNotContains('tasks:create:all', $result);
$this->assertNotContains('tasks:update:all', $result);
$this->assertNotContains('tasks:delete:all', $result);
}
} }

View File

@@ -6,6 +6,7 @@
"compilerOptions": { "compilerOptions": {
"paths": { "paths": {
"@/*": ["./resources/js/*"], "@/*": ["./resources/js/*"],
"@solidtime/ui": ["./resources/js/packages/ui/src/index.ts"]
} }
}, },
"skipLibCheck": true, "skipLibCheck": true,