Compare commits

...

11 Commits

51 changed files with 1447 additions and 174 deletions

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TimeEntryResource\Pages;
use App\Models\Member;
use App\Models\TimeEntry;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
@@ -16,6 +17,7 @@ use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class TimeEntryResource extends Resource
{
@@ -51,6 +53,20 @@ class TimeEntryResource extends Resource
->rules([
'after_or_equal:start',
]),
Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->required(),
Select::make('member_id')
->relationship(
name: 'member',
titleAttribute: 'id',
modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization'])
)
->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')')
->searchable()
->preload()
->required(),
Select::make('user_id')
->relationship(name: 'user', titleAttribute: 'email')
->searchable(['name', 'email'])
@@ -59,7 +75,10 @@ class TimeEntryResource extends Resource
->relationship(name: 'project', titleAttribute: 'name')
->searchable(['name'])
->nullable(),
// TODO
Select::make('task_id')
->relationship(name: 'task', titleAttribute: 'name')
->searchable(['name'])
->nullable(),
]);
}

View File

@@ -5,9 +5,28 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Resources\Pages\CreateRecord;
class CreateTimeEntry extends CreateRecord
{
protected static string $resource = TimeEntryResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
if (isset($data['member_id'])) {
/** @var Member|null $member */
$member = Member::query()->find($data['member_id']);
if ($member !== null) {
$data['user_id'] = $member->user_id;
$data['organization_id'] = $member->organization_id;
}
}
return $data;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
@@ -19,4 +20,22 @@ class EditTimeEntry extends EditRecord
->icon('heroicon-m-trash'),
];
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeSave(array $data): array
{
if (isset($data['member_id'])) {
/** @var Member|null $member */
$member = Member::query()->find($data['member_id']);
if ($member !== null) {
$data['user_id'] = $member->user_id;
$data['organization_id'] = $member->organization_id;
}
}
return $data;
}
}

View File

@@ -10,8 +10,10 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -42,7 +44,16 @@ class TimeEntryStoreRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
$builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:create:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
})->uuid(),
],
// ID of the task that the time entry should belong to

View File

@@ -10,8 +10,10 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -54,7 +56,16 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
$builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
})->uuid(),
],
// ID of the task that the time entry should belong to

View File

@@ -10,8 +10,10 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -42,7 +44,16 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
$builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
})->uuid(),
],
// ID of the task that the time entry should belong to

View File

@@ -31,12 +31,17 @@ class TimeEntryService
throw new LogicException('Rounding minutes must be greater than 0');
}
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
} elseif ($roundingType === TimeEntryRoundingType::Up) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
// If end is already on a boundary, keep it; otherwise round up to next boundary
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
'THEN '.$end.' '.
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
'END';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
}
}
}

View File

@@ -107,7 +107,7 @@ services:
- sail
- reverse-proxy
playwright:
image: mcr.microsoft.com/playwright:v1.51.1-jammy
image: mcr.microsoft.com/playwright:v1.57.0-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src
extra_hosts:

View File

@@ -8,6 +8,13 @@ async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
}
// Helper to clear localStorage before tests that check persistence
async function clearProjectTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('project-table-state');
});
}
// Create new project via modal
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
@@ -45,34 +52,62 @@ test('test that creating and deleting a new project via the modal works', async
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
});
// Helper to select a status filter using the new dropdown UI
async function selectStatusFilter(page: Page, status: 'Active' | 'Archived') {
// Click the Filter button to open the dropdown
await page.getByRole('button', { name: 'Filter projects' }).click();
// Click on Status submenu
await page.getByRole('menuitem', { name: 'Status' }).click();
// Select the status option
await page.getByRole('menuitem', { name: status }).click();
}
// Helper to remove status filter by clicking the X on the badge
async function removeStatusFilter(page: Page) {
const statusBadge = page.getByTestId('status-filter-badge');
// Click the remove button (second button in the badge, contains XMarkIcon)
await statusBadge.locator('button').last().click();
}
test('test that archiving and unarchiving projects works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible();
// Archive the project
await page.getByRole('row').first().getByRole('button').click();
await Promise.all([
page.getByRole('menuitem').getByText('Archive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(),
]);
await Promise.all([
page.getByRole('tab', { name: 'Archived' }).click(),
expect(page.getByText(newProjectName)).toBeVisible(),
]);
await page.getByRole('menuitem').getByText('Archive').first().click();
// Project should still be visible since default is "all" (no filter)
await expect(page.getByText(newProjectName)).toBeVisible();
// Apply Active filter - archived project should disappear
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).not.toBeVisible();
// Remove Active filter and apply Archived filter
await removeStatusFilter(page);
await selectStatusFilter(page, 'Archived');
await expect(page.getByText(newProjectName)).toBeVisible();
// Unarchive the project
await page.getByRole('row').first().getByRole('button').click();
await Promise.all([
page.getByRole('menuitem').getByText('Unarchive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(),
]);
await Promise.all([
page.getByRole('tab', { name: 'Active' }).click(),
expect(page.getByText(newProjectName)).toBeVisible(),
]);
await page.getByRole('menuitem').getByText('Unarchive').first().click();
// Project should disappear from Archived view
await expect(page.getByText(newProjectName)).not.toBeVisible();
// Remove Archived filter and apply Active filter to see the project
await removeStatusFilter(page);
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).toBeVisible();
});
test('test that updating billable rate works with existing time entries', async ({ page }) => {
@@ -116,6 +151,147 @@ test('test that updating billable rate works with existing time entries', async
).toBeVisible();
});
// Sorting tests
test('test that sorting projects by name works', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Wait for the table to load
await expect(page.getByTestId('project_table')).toBeVisible();
// Get initial project names
const getProjectNames = async () => {
const rows = page
.getByTestId('project_table')
.locator('[data-testid="project_table"] > div')
.filter({ hasNot: page.locator('.border-t') });
const names: string[] = [];
const count = await page.getByTestId('project_table').getByRole('row').count();
for (let i = 0; i < count; i++) {
const row = page.getByTestId('project_table').getByRole('row').nth(i);
const nameCell = row.locator('div').first();
const text = await nameCell.textContent();
if (text) {
names.push(text.trim());
}
}
return names;
};
// Click on Name header to sort ascending (default should already be ascending)
const nameHeader = page.getByText('Name').first();
await nameHeader.click();
// Wait for sort to apply
await page.waitForTimeout(100);
// Click again to sort descending
await nameHeader.click();
await page.waitForTimeout(100);
// Verify the sort indicator is showing descending
await expect(page.locator('svg').first()).toBeVisible();
});
test('test that sorting projects by status works', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Default is "all" so no filter needed - Wait for the table to load
await expect(page.getByTestId('project_table')).toBeVisible();
// Click on Status header to sort
const statusHeader = page.getByText('Status').first();
await statusHeader.click();
// Wait for sort to apply
await page.waitForTimeout(100);
// Sort indicator should be visible
await expect(statusHeader.locator('svg')).toBeVisible();
});
// Filter tests
test('test that filtering projects by status works', async ({ page }) => {
const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Create a new project
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible();
// Archive the project
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Archive').first().click();
// Project should still be visible (default is "all" - no filter)
await expect(page.getByText(newProjectName)).toBeVisible();
// Apply Active filter - archived project should disappear
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).not.toBeVisible();
// Remove Active filter - project should reappear (back to "all")
await removeStatusFilter(page);
await expect(page.getByText(newProjectName)).toBeVisible();
// Apply Archived filter - project should still be visible
await selectStatusFilter(page, 'Archived');
await expect(page.getByText(newProjectName)).toBeVisible();
// Remove Archived filter and apply Active filter - project should not be visible
await removeStatusFilter(page);
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).not.toBeVisible();
});
test('test that filter state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Apply Active status filter
await selectStatusFilter(page, 'Active');
// Verify the filter badge is visible
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
// Wait for the state to be saved
await page.waitForTimeout(100);
// Reload the page
await page.reload();
// Verify the filter badge is still visible after reload
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
});
test('test that sort state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Click on Name header twice to sort descending
const nameHeader = page.getByText('Name').first();
await nameHeader.click();
await nameHeader.click();
// Wait for the state to be saved
await page.waitForTimeout(100);
// Reload the page
await page.reload();
// Verify descending sort indicator is visible on Name column
await expect(page.getByTestId('project_table')).toBeVisible();
});
// Create new project with new Client
// Create new project with existing Client
@@ -124,8 +300,6 @@ test('test that updating billable rate works with existing time entries', async
// Test that project task count is displayed correctly
// Test that active / archive / all filter works (once implemented)
// Edit Project Modal Test
// Add Project with billable rate

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { XMarkIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { Component } from 'vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
defineProps<{
icon: Component;
label: string;
filterName: string;
}>();
defineEmits<{
remove: [];
}>();
defineSlots<{
default(): void;
}>();
</script>
<template>
<div
class="inline-flex items-center gap-0.5 rounded-md bg-tertiary dark:bg-secondary border border-border-secondary">
<DropdownMenu>
<DropdownMenuTrigger
class="inline-flex items-center gap-1.5 px-2 py-1 text-sm hover:bg-quaternary dark:hover:bg-tertiary rounded-l-md transition-colors whitespace-nowrap">
<component :is="icon" class="h-3.5 w-3.5 text-icon-default" />
<span class="font-medium text-foreground">{{ filterName }}</span>
<span class="text-muted-foreground">is</span>
<span class="text-foreground">{{ label }}</span>
<ChevronDownIcon class="h-3 w-3 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<slot />
</DropdownMenuContent>
</DropdownMenu>
<button
class="px-1.5 py-1 hover:bg-quaternary dark:hover:bg-tertiary h-full rounded-r-md transition-colors group border-l border-border-secondary"
@click="$emit('remove')">
<XMarkIcon class="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />
</button>
</div>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue';
import { UserGroupIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/Components/ui/dropdown-menu';
import BaseFilterBadge from './BaseFilterBadge.vue';
import type { Client } from '@/packages/api/src';
import { NO_CLIENT_ID } from './constants';
const props = defineProps<{
value: string[];
clients: Client[];
}>();
const emit = defineEmits<{
remove: [];
'update:value': [value: string[]];
}>();
const hasNoClient = computed(() => props.value.includes(NO_CLIENT_ID));
const label = computed(() => {
const count = props.value.length;
if (count === 0) return 'None';
if (count === 1) {
if (hasNoClient.value) return 'No client';
const client = props.clients.find((c) => c.id === props.value[0]);
return client?.name ?? 'Client';
}
return `${count} selected`;
});
function toggleClient(clientId: string) {
const clientIds = props.value.includes(clientId)
? props.value.filter((id) => id !== clientId)
: [...props.value, clientId];
emit('update:value', clientIds);
}
function toggleNoClient() {
const clientIds = hasNoClient.value
? props.value.filter((id) => id !== NO_CLIENT_ID)
: [...props.value, NO_CLIENT_ID];
emit('update:value', clientIds);
}
</script>
<template>
<BaseFilterBadge
:icon="UserGroupIcon"
:label="label"
filter-name="Client"
@remove="emit('remove')">
<DropdownMenuCheckboxItem :model-value="hasNoClient" @select.prevent="toggleNoClient">
No client
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
v-for="client in clients"
:key="client.id"
:model-value="value.includes(client.id)"
@select.prevent="toggleClient(client.id)">
{{ client.name }}
</DropdownMenuCheckboxItem>
</BaseFilterBadge>
</template>

View File

@@ -130,7 +130,7 @@ function updateValue(project: Project) {
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a project..."
@keydown.enter="addProjectIfNoneExists" />
</ComboboxAnchor>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue';
import { CircleStackIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuItem } from '@/Components/ui/dropdown-menu';
import BaseFilterBadge from './BaseFilterBadge.vue';
type StatusValue = 'active' | 'archived' | 'all';
const props = defineProps<{
value: StatusValue;
}>();
const emit = defineEmits<{
remove: [];
'update:value': [value: StatusValue];
}>();
const statusOptions = [
{ id: 'active' as const, name: 'Active' },
{ id: 'archived' as const, name: 'Archived' },
];
const label = computed(() => {
return statusOptions.find((opt) => opt.id === props.value)?.name ?? 'Status';
});
function updateStatus(status: StatusValue) {
emit('update:value', status);
}
</script>
<template>
<BaseFilterBadge
:icon="CircleStackIcon"
:label="label"
filter-name="Status"
@remove="emit('remove')">
<DropdownMenuItem
v-for="option in statusOptions"
:key="option.id"
:class="[value === option.id && 'bg-accent text-accent-foreground']"
@click="updateStatus(option.id)">
{{ option.name }}
</DropdownMenuItem>
</BaseFilterBadge>
</template>

View File

@@ -4,7 +4,10 @@ import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { computed, ref } from 'vue';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableHeading, {
type SortColumn,
type SortDirection,
} from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
@@ -12,13 +15,96 @@ import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
const props = defineProps<{
projects: Project[];
showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
const { clients } = storeToRefs(useClientsStore());
// Create a map of client names for sorting
const clientNameMap = computed(() => {
const map = new Map<string, string>();
clients.value.forEach((client) => {
map.set(client.id, client.name);
});
return map;
});
// Convert our sort state to TanStack Table format
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
// Define column accessors for sorting
const columns = [
{
id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(),
},
{
id: 'client_name',
accessorFn: (row: Project) => {
if (!row.client_id) return '';
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
},
},
{
id: 'spent_time',
accessorFn: (row: Project) => row.spent_time ?? 0,
},
{
id: 'billable_rate',
accessorFn: (row: Project) => row.billable_rate ?? 0,
},
{
id: 'status',
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
},
];
const table = useVueTable({
get data() {
return props.projects;
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedProjects = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
function handleSort(column: SortColumn) {
emit('sort', column);
}
const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
@@ -26,11 +112,10 @@ async function createProject(project: CreateProjectBody): Promise<Project | unde
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
return await useClientsStore().createClient(client);
}
const { clients } = storeToRefs(useClientsStore());
const gridTemplate = computed(() => {
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
});
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
</script>
<template>
@@ -45,8 +130,11 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
<div class="inline-block min-w-full align-middle">
<div data-testid="project_table" class="grid min-w-full" :style="gridTemplate">
<ProjectTableHeading
:show-billable-rate="props.showBillableRate"></ProjectTableHeading>
<div v-if="projects.length === 0" class="col-span-5 py-24 text-center">
:show-billable-rate="props.showBillableRate"
:sort-column="props.sortColumn"
:sort-direction="props.sortDirection"
@sort="handleSort"></ProjectTableHeading>
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">
{{
@@ -69,7 +157,7 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
>Create your First Project
</SecondaryButton>
</div>
<template v-for="project in projects" :key="project.id">
<template v-for="project in sortedProjects" :key="project.id">
<ProjectTableRow
:show-billable-rate="props.showBillableRate"
:project="project"></ProjectTableRow>

View File

@@ -1,23 +1,89 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
defineProps<{
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
export type SortColumn = 'name' | 'client_name' | 'spent_time' | 'billable_rate' | 'status';
export type SortDirection = 'asc' | 'desc';
const props = defineProps<{
showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
</script>
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
Name
<ChevronDownIcon v-if="isSorted('name') && sortDirection === 'asc'" class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('name') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Client</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
<div v-if="showBillableRate" class="px-3 py-1.5 text-left font-semibold text-text-primary">
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('client_name')">
Client
<ChevronDownIcon
v-if="isSorted('client_name') && sortDirection === 'asc'"
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('client_name') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('spent_time')">
Total Time
<ChevronDownIcon
v-if="isSorted('spent_time') && sortDirection === 'asc'"
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('spent_time') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
<div
v-if="showBillableRate"
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('billable_rate')">
Billable Rate
<ChevronDownIcon
v-if="isSorted('billable_rate') && sortDirection === 'asc'"
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('billable_rate') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
Status
<ChevronDownIcon v-if="isSorted('status') && sortDirection === 'asc'" class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('status') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -2,7 +2,7 @@
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks';
@@ -116,9 +116,15 @@ const showEditProjectModal = ref(false);
{{ billableRateInfo }}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<CheckCircleIcon class="w-5"></CheckCircleIcon>
<span>Active</span>
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuCheckboxItem,
DropdownMenuSeparator,
} from '@/Components/ui/dropdown-menu';
import { Button } from '@/packages/ui/src';
import type { Client } from '@/packages/api/src';
import { NO_CLIENT_ID } from './constants';
export interface ProjectFilters {
status: 'active' | 'archived' | 'all';
clientIds: string[];
}
const props = defineProps<{
filters: ProjectFilters;
clients: Client[];
}>();
const emit = defineEmits<{
'update:filters': [filters: ProjectFilters];
}>();
const statusOptions = [
{ id: 'active' as const, name: 'Active' },
{ id: 'archived' as const, name: 'Archived' },
];
const open = ref(false);
function updateStatus(status: 'active' | 'archived' | 'all') {
emit('update:filters', {
...props.filters,
status,
});
open.value = false;
}
function toggleClient(clientId: string) {
const clientIds = props.filters.clientIds.includes(clientId)
? props.filters.clientIds.filter((id) => id !== clientId)
: [...props.filters.clientIds, clientId];
emit('update:filters', {
...props.filters,
clientIds,
});
}
function toggleNoClient() {
const clientIds = props.filters.clientIds.includes(NO_CLIENT_ID)
? props.filters.clientIds.filter((id) => id !== NO_CLIENT_ID)
: [...props.filters.clientIds, NO_CLIENT_ID];
emit('update:filters', {
...props.filters,
clientIds,
});
}
const hasActiveFilters = computed(() => {
return props.filters.status !== 'all' || props.filters.clientIds.length > 0;
});
</script>
<template>
<DropdownMenu v-model:open="open">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="xs" aria-label="Filter projects">
<ListFilterIcon
:class="[hasActiveFilters ? '' : '-ml-0.5', 'h-4 w-4 text-icon-default']" />
<span v-if="!hasActiveFilters" class="text-nowrap">Filter</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-56">
<!-- Status Filter -->
<DropdownMenuSub>
<DropdownMenuSubTrigger class="gap-2">
<CheckCircleIcon class="h-4 w-4 text-icon-default" />
<span>Status</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
v-for="option in statusOptions"
:key="option.id"
:class="[
filters.status === option.id && 'bg-accent text-accent-foreground',
]"
@click="updateStatus(option.id)">
{{ option.name }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<!-- Client Filter -->
<DropdownMenuSub v-if="clients.length > 0">
<DropdownMenuSubTrigger class="gap-2">
<UserGroupIcon class="h-4 w-4 text-icon-default" />
<span>Client</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent class="max-h-[300px] overflow-y-auto">
<DropdownMenuCheckboxItem
:model-value="filters.clientIds.includes(NO_CLIENT_ID)"
@select.prevent="toggleNoClient">
No client
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
v-for="client in clients"
:key="client.id"
:model-value="filters.clientIds.includes(client.id)"
@select.prevent="toggleClient(client.id)">
{{ client.name }}
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -0,0 +1 @@
export const NO_CLIENT_ID = '__no_client__';

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
<template>
<div
class="contents [&>*]:border-row-separator text-xs sm:text-sm [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background">
class="contents [&>*]:border-row-separator text-xs [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background">
<slot></slot>
</div>
</template>

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
<template>
<section class="flex flex-col">
<CardTitle :title="title" :icon="icon"></CardTitle>
<div
class="rounded-lg bg-card-background border border-card-border flex-1 flex items-stretch shadow-card">
<div class="rounded-lg border border-card-border flex-1 flex items-stretch">
<div class="w-full flex flex-col">
<slot></slot>
</div>

View File

@@ -15,9 +15,7 @@ const delegatedProps = computed(() => {
<template>
<TabsList
v-bind="delegatedProps"
:class="
cn('inline-flex items-center rounded-lg bg-muted text-muted-foreground', props.class)
">
:class="cn('inline-flex items-center rounded-lg text-muted-foreground', props.class)">
<slot />
</TabsList>
</template>

View File

@@ -35,7 +35,7 @@ const refreshDashboardData = () => {
</MainContainer>
<MainContainer
class="grid gap-5 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch">
class="grid gap-2 sm:gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch">
<RecentlyTrackedTasksCard></RecentlyTrackedTasksCard>
<LastSevenDaysCard></LastSevenDaysCard>
<ActivityGraphCard></ActivityGraphCard>

View File

@@ -4,13 +4,16 @@ import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
import { computed, onMounted, ref } from 'vue';
import type {
SortColumn,
SortDirection,
} from '@/Components/Common/Project/ProjectTableHeading.vue';
import { computed } from 'vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useProjectsStore } from '@/utils/useProjects';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateProjects } from '@/utils/permissions';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import { storeToRefs } from 'pinia';
import { useClientsStore } from '@/utils/useClients';
import type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src';
@@ -18,31 +21,95 @@ import { getOrganizationCurrencyString } from '@/utils/money';
import { getCurrentRole } from '@/utils/useUser';
import { useOrganizationStore } from '@/utils/useOrganization';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useStorage } from '@vueuse/core';
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
// Fetch data using TanStack Query
const { projects } = useProjectsQuery();
onMounted(() => {
useProjectsStore().fetchProjects();
useOrganizationStore().fetchOrganization();
});
const { clients } = storeToRefs(useClientsStore());
const showCreateProjectModal = ref(false);
const { organization } = storeToRefs(useOrganizationStore());
const activeTab = ref<'active' | 'archived'>('active');
// Table state persisted in localStorage
interface ProjectTableState {
sortColumn: SortColumn;
sortDirection: SortDirection;
filters: {
clientIds: string[];
status: 'active' | 'archived' | 'all';
};
}
const { projects } = storeToRefs(useProjectsStore());
const tableState = useStorage<ProjectTableState>(
'project-table-state',
{
sortColumn: 'name',
sortDirection: 'asc',
filters: {
clientIds: [],
status: 'all',
},
},
undefined,
{ mergeDefaults: true }
);
const shownProjects = computed(() => {
// Handle sorting - toggle direction if same column, otherwise set new column with asc
function handleSort(column: SortColumn) {
if (tableState.value.sortColumn === column) {
tableState.value.sortDirection = tableState.value.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
tableState.value.sortColumn = column;
tableState.value.sortDirection = 'asc';
}
}
// Filter projects based on current filters
const filteredProjects = computed(() => {
return projects.value.filter((project) => {
if (activeTab.value === 'active') {
return !project.is_archived;
// Status filter
if (tableState.value.filters.status === 'active' && project.is_archived) {
return false;
}
return project.is_archived;
if (tableState.value.filters.status === 'archived' && !project.is_archived) {
return false;
}
// Client filter
const hasClientFilter = tableState.value.filters.clientIds.length > 0;
if (hasClientFilter) {
const matchesNoClient =
tableState.value.filters.clientIds.includes(NO_CLIENT_ID) && !project.client_id;
const matchesClientId =
project.client_id && tableState.value.filters.clientIds.includes(project.client_id);
if (!matchesNoClient && !matchesClientId) {
return false;
}
}
return true;
});
});
// Helper functions for active filters
function removeStatusFilter() {
tableState.value.filters.status = 'all';
}
function removeClientFilter() {
tableState.value.filters.clientIds = [];
}
const showCreateProjectModal = useStorage('project-create-modal-open', false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
return await useClientsStore().createClient(client);
}
@@ -57,13 +124,9 @@ const showBillableRate = computed(() => {
<template>
<AppLayout title="Projects" data-testid="projects_view">
<MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
class="py-3 sm:pt-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
<TabBar v-model="activeTab">
<TabBarItem value="active">Active</TabBarItem>
<TabBarItem value="archived">Archived</TabBarItem>
</TabBar>
</div>
<SecondaryButton
v-if="canCreateProjects()"
@@ -80,8 +143,38 @@ const showBillableRate = computed(() => {
:clients="clients"
@submit="createProject"></ProjectCreateModal>
</MainContainer>
<MainContainer>
<div class="flex items-center gap-2 py-1">
<ProjectsFilterDropdown
:filters="tableState.filters"
:clients="clients"
@update:filters="tableState.filters = $event" />
<!-- Active Filters -->
<ProjectStatusFilterBadge
v-if="tableState.filters.status !== 'all'"
data-testid="status-filter-badge"
:value="tableState.filters.status"
@remove="removeStatusFilter"
@update:value="
tableState.filters.status = $event as 'active' | 'archived' | 'all'
" />
<ProjectClientFilterBadge
v-if="tableState.filters.clientIds.length > 0"
data-testid="client-filter-badge"
:value="tableState.filters.clientIds"
:clients="clients"
@remove="removeClientFilter"
@update:value="tableState.filters.clientIds = $event as string[]" />
</div>
</MainContainer>
<ProjectTable
:show-billable-rate="showBillableRate"
:projects="shownProjects"></ProjectTable>
:projects="filteredProjects"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"
@sort="handleSort"></ProjectTable>
</AppLayout>
</template>

View File

@@ -92,7 +92,7 @@ function updateValue(client: { id: string | null; name: string }) {
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a client..." />
</ComboboxAnchor>
<ComboboxContent>

View File

@@ -6,10 +6,10 @@ import type { Dayjs } from 'dayjs';
const props = defineProps<{
date: Dayjs;
totalMinutes?: number;
totalSeconds?: number;
}>();
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
// Injected organization for formatting settings
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
@@ -25,7 +25,7 @@ const dateFormat = computed(() => organization?.value?.date_format);
</div>
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
<span class="block text-xs text-muted-foreground font-medium mt-1">
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
</span>
</div>
</template>

View File

@@ -179,20 +179,20 @@ const dailyTotals = computed(() => {
const totals: Record<string, number> = {};
props.timeEntries.forEach((entry) => {
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
let duration: number;
let durationSeconds: number;
if (entry.end !== null) {
// Completed entry
duration = getDayJsInstance()(entry.end).diff(
durationSeconds = getDayJsInstance()(entry.end).diff(
getDayJsInstance()(entry.start),
'minutes'
'seconds'
);
} else {
// Running entry - use current time
duration = currentTime.value.diff(getDayJsInstance()(entry.start), 'minutes');
durationSeconds = currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds');
}
totals[date] = (totals[date] || 0) + duration;
totals[date] = (totals[date] || 0) + durationSeconds;
});
return totals;
});
@@ -444,7 +444,7 @@ onUnmounted(() => {
:date="
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
"
:total-minutes="
:total-seconds="
dailyTotals[
getDayJsInstance()(arg.date)
.utc()
@@ -548,7 +548,7 @@ onUnmounted(() => {
}
.fullcalendar :deep(.fc-day-today.fc-col-header-cell) {
background-color: var(--color-accent-default);
background-color: var(--color-bg-secondary);
}
.fullcalendar :deep(.fc-day-today) {

View File

@@ -0,0 +1,20 @@
<script setup lang="ts"></script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M2 5h20" />
<path d="M6 12h12" />
<path d="M9 19h6" />
</svg>
</template>
<style scoped></style>

View File

@@ -52,7 +52,7 @@ watch(open, (value) => {
</PopoverTrigger>
<PopoverContent
:align="align"
class="rounded-lg overflow-hidden relative border border-card-border overflow-none shadow-dropdown bg-card-background"
class="rounded-lg overflow-hidden relative border border-card-border overflow-none shadow-dropdown bg-secondary"
@open-auto-focus="handleAutofocus"
@click="onContentClick">
<slot name="content" />

View File

@@ -145,7 +145,7 @@ const highlightedItem = computed(() => {
<input
ref="searchInput"
:value="searchValue"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
:placeholder="searchPlaceholder"
@input="updateSearchValue"
@keydown.up.prevent="moveHighlightUp"

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"></script>
<template>
<div class="px-3 sm:px-4 lg:px-8 3xl:px-12 mx-auto">
<div class="px-3 sm:px-4 lg:px-6 mx-auto">
<slot></slot>
</div>
</template>

View File

@@ -171,7 +171,7 @@ const showCreateTagModal = ref(false);
ref="searchInput"
:value="searchValue"
data-testid="tag_dropdown_search"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a Tag..."
@input="updateSearchValue"
@keydown.esc.prevent="open = false"

View File

@@ -63,7 +63,7 @@ const showMassUpdateModal = ref(false);
:class="
twMerge(
props.class,
'text-sm py-1.5 font-medium bg-secondary flex items-center space-x-3'
'text-sm py-1.5 font-medium flex border-b border-border-primary items-center space-x-3'
)
">
<Checkbox

View File

@@ -8,6 +8,7 @@ import {
import Checkbox from '../Input/Checkbox.vue';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
import { CalendarIcon } from '@heroicons/vue/20/solid';
const organization = inject<ComputedRef<Organization>>('organization');
@@ -32,32 +33,24 @@ function selectUnselectAll(value: boolean) {
<template>
<div
class="bg-row-heading-background border-t border-b border-row-heading-border py-1 text-xs @sm:text-sm">
class="bg-background dark:bg-quaternary border-b border-border-primary py-1 text-xs @sm:text-sm">
<MainContainer>
<div class="flex group justify-between items-center">
<div class="flex items-center space-x-2">
<div class="w-5">
<svg
class="w-3 @sm:w-4 text-icon-default group-hover:hidden block"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path
d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7zm-5-9a1 1 0 0 1 1 1v1h2a2 2 0 0 1 2 2v3H3V7a2 2 0 0 1 2-2h2V4a1 1 0 0 1 2 0v1h6V4a1 1 0 0 1 1-1" />
</g>
</svg>
<CalendarIcon
class="w-3 @sm:w-4 text-icon-default group-hover:hidden block">
</CalendarIcon>
<Checkbox
:checked="checked"
class="group-hover:block hidden"
@update:checked="selectUnselectAll"></Checkbox>
</div>
<span class="font-medium text-text-primary">
<span class="font-medium text-text-secondary">
{{ formatWeekday(date) }}
</span>
<span class="font-medium text-text-secondary">
<span class="text-text-tertiary">
{{ formatDate(date, organization?.date_format) }}
</span>
</div>

View File

@@ -207,7 +207,7 @@ useSelectEvents(
<template>
<div class="flex items-center relative @container" data-testid="dashboard_timer">
<div
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-secondary border-card-border border transition shadow-card">
<div class="flex flex-1 items-center pr-6 relative">
<input
ref="currentTimeEntryDescriptionInput"

View File

@@ -543,7 +543,7 @@ const showCreateProject = ref(false);
ref="searchInput"
:value="searchValue"
data-testid="client_dropdown_search"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a project or task..."
@input="updateSearchValue"
@keydown.enter.prevent="addClientIfNoneExists"

View File

@@ -154,7 +154,7 @@ function closeAndFocusInput() {
v-model="currentTime"
placeholder="00:00:00"
data-testid="time_entry_time"
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-muted focus:ring-0 transition"
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-secondary border-none placeholder-text-tertiary focus:ring-0 transition"
type="text"
@focusin="openModalOnTab"
@click="openModalOnClick"

View File

@@ -33,14 +33,14 @@
--theme-color-chart: var(--color-accent-200);
--theme-color-menu-active: var(--color-bg-secondary);
--theme-color-card-background: var(--color-bg-secondary);
--theme-color-card-background: var(--color-bg-primary);
--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-background: var(--color-bg-primary);
--theme-color-row-heading-border: var(--theme-color-card-border);
--theme-color-icon-default: var(--color-text-tertiary);
@@ -51,7 +51,7 @@
--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-background: transparent;
--theme-color-input-select-active: rgb(var(--color-accent-300));
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
@@ -63,7 +63,7 @@
:root.light {
--color-bg-primary: #ffffff;
--color-bg-secondary: #f7f7f8;
--color-bg-secondary: #fcfcfc;
--color-bg-tertiary: #eeeeef;
--color-bg-quaternary: #e1e1e3;
--color-bg-background: #f5f5f5;
@@ -86,8 +86,8 @@
--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-background: var(--theme-color-primary);
--theme-color-row-heading-background: var(--theme-color-primary);
--theme-color-row-heading-border: var(--color-border-tertiary);
--theme-color-icon-default: var(--color-text-quaternary);
@@ -98,7 +98,7 @@
--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-background: transparent;
--theme-color-input-select-active: rgb(var(--color-accent-400));
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
@@ -142,28 +142,8 @@
* {
-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;
scrollbar-width: thin;
scrollbar-color: var(--color-bg-tertiary) transparent;
}
[x-cloak] {
@@ -188,8 +168,8 @@ body {
--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);
--accent: var(--color-bg-tertiary);
--accent-foreground: var(--color-text-primary);
--destructive: 0 84.2% 60.2%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
@@ -207,7 +187,7 @@ body {
--foreground: var(--color-text-primary);
--card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary);
--popover: var(--theme-color-card-background);
--popover: var(--color-bg-tertiary);
--popover-foreground: var(--color-text-primary);
--primary: var(--color-bg-primary);
--primary-foreground: var(--color-text-primary);
@@ -215,8 +195,8 @@ body {
--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);
--accent: var(--color-bg-tertiary);
--accent-foreground: var(--color-text-primary);
--destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);

View File

@@ -38,7 +38,7 @@ export const solidtimeTheme = {
foreground: 'var(--primary-foreground)',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
DEFAULT: 'var(--secondary)',
foreground: 'hsl(var(--secondary-foreground))',
},
tertiary: 'var(--color-bg-tertiary)',
@@ -60,8 +60,8 @@ export const solidtimeTheme = {
'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))',
DEFAULT: 'var(--muted)',
foreground: 'var(--muted-foreground)',
},
'tab-background': 'var(--theme-color-tab-background)',
'tab-background-active': 'var(--theme-color-tab-background-active)',
@@ -90,8 +90,8 @@ export const solidtimeTheme = {
'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)',
DEFAULT: 'var(--accent)',
foreground: 'var(--accent-foreground)',
},
'button-primary-background': 'var(--theme-color-button-primary-background)',
'button-primary-background-hover': 'var(--theme-color-button-primary-background-hover)',

View File

@@ -9,10 +9,16 @@ import type {
} from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useQueryClient } from '@tanstack/vue-query';
export const useProjectsStore = defineStore('projects', () => {
const projectResponse = ref<ProjectResponse | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
function invalidateProjectsQuery() {
queryClient.invalidateQueries({ queryKey: ['projects'] });
}
async function fetchProjects() {
const organization = getCurrentOrganizationId();
if (organization) {
@@ -48,6 +54,7 @@ export const useProjectsStore = defineStore('projects', () => {
);
await fetchProjects();
invalidateProjectsQuery();
return response['data'];
}
}
@@ -67,6 +74,7 @@ export const useProjectsStore = defineStore('projects', () => {
'Failed to delete project'
);
await fetchProjects();
invalidateProjectsQuery();
}
}
@@ -85,6 +93,7 @@ export const useProjectsStore = defineStore('projects', () => {
'Failed to update project'
);
await fetchProjects();
invalidateProjectsQuery();
}
}

View File

@@ -0,0 +1,34 @@
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Project } from '@/packages/api/src';
import { computed } from 'vue';
export function useProjectsQuery() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['projects'],
queryFn: async () => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization');
return api.getProjects({
params: { organization: organizationId },
queries: { archived: 'all' },
});
},
enabled: () => !!getCurrentOrganizationId(),
});
const projects = computed<Project[]>(() => query.data.value?.data ?? []);
const invalidateProjects = () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
};
return {
...query,
projects,
invalidateProjects,
};
}

View File

@@ -14,6 +14,7 @@ export default {
'./resources/views/**/*.blade.php',
'./resources/js/**/*.vue',
'./resources/js/**/*.ts',
'!./resources/js/**/node_modules',
],
theme: {
extend: {

View File

@@ -436,6 +436,52 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
);
}
public function test_index_endpoint_can_round_up_but_does_not_round_up_if_already_on_border(): void
{
// Arrange
$this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),
'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:06:00'),
]);
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
'end' => null,
]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'member_id' => $data->member->getKey(),
'rounding_type' => TimeEntryRoundingType::Up,
'rounding_minutes' => 6,
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('meta')
->where('meta.total', 2)
->count('data', 2)
->where('data.0.id', $timeEntry1->getKey())
->where('data.0.start', '2020-01-01T00:00:00Z')
->where('data.0.end', '2020-01-01T00:06:00Z')
->where('data.1.id', $timeEntry2->getKey())
->where('data.1.start', '2020-01-01T00:00:00Z')
->where('data.1.end', '2020-01-01T00:18:00Z')
);
}
public function test_index_endpoint_ignores_rounding_if_organization_has_no_premium_features(): void
{
// Arrange
@@ -1922,6 +1968,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->withTags($data->organization)->make();
@@ -1949,6 +1996,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
$timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
@@ -1978,6 +2026,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
$timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
@@ -2007,6 +2056,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$timeEntryFake = TimeEntry::factory()->withTask($data->organization)->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
@@ -2037,6 +2087,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$timeEntryFake = TimeEntry::factory()->withTask($data->organization)->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
@@ -2053,11 +2104,47 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertStatus(422);
}
public function test_store_endpoint_fails_if_employee_tries_to_create_time_entry_for_private_project_without_access(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
// Create a private project that the employee is not a member of
$privateProject = Project::factory()->forOrganization($data->organization)->create([
'is_public' => false,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'description' => 'Test time entry',
'billable' => false,
'start' => now()->toIso8601ZuluString(),
'end' => now()->addHour()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
'project_id' => $privateProject->getKey(),
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['project_id']);
// Verify the time entry was NOT created in the database
$this->assertDatabaseMissing(TimeEntry::class, [
'project_id' => $privateProject->getKey(),
'member_id' => $data->member->getKey(),
]);
}
public function test_store_endpoints_sets_billable_rate(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$timeEntryFake = TimeEntry::factory()->withTask($data->organization)->forOrganization($data->organization)->make();
$project = Project::factory()->forOrganization($data->organization)->billable()->create();
@@ -2088,6 +2175,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
@@ -2113,6 +2201,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
@@ -2143,6 +2232,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
@@ -2202,6 +2292,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
@@ -2236,6 +2327,39 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
});
}
public function test_update_endpoint_fails_if_employee_tries_to_update_time_entry_to_private_project_without_access(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
// Create a time entry for the employee
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
// Create a private project that the employee is not a member of
$privateProject = Project::factory()->forOrganization($data->organization)->create([
'is_public' => false,
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
'project_id' => $privateProject->getKey(),
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['project_id']);
// Verify the time entry was NOT updated in the database
$this->assertDatabaseMissing(TimeEntry::class, [
'id' => $timeEntry->getKey(),
'project_id' => $privateProject->getKey(),
]);
}
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_own_time_entries(): void
{
// Arrange
@@ -2264,9 +2388,11 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$otherUser = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($otherUser->organization)->forMember($otherUser->member)->create();
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();
@@ -2290,6 +2416,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$user = User::factory()->create();
$member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create();
@@ -2315,6 +2442,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
@@ -2344,6 +2472,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
@@ -2373,6 +2502,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntryFake = TimeEntry::factory()->withTags($data->organization)->forOrganization($data->organization)->make();
@@ -2401,6 +2531,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntryFake = TimeEntry::factory()->withTags($data->organization)->forOrganization($data->organization)->make();
@@ -2432,6 +2563,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntryFake = TimeEntry::factory()->withTags($data->organization)->forOrganization($data->organization)->make();
@@ -2459,6 +2591,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();
@@ -2576,6 +2709,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
@@ -2610,6 +2744,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$oldProject = Project::factory()->forOrganization($data->organization)->create();
$oldTask = Task::factory()->forOrganization($data->organization)->forProject($oldProject)->create();
@@ -3029,11 +3164,53 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_update_multiple_endpoint_fails_if_employee_tries_to_update_time_entries_to_private_project_without_access(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
// Create time entries for the employee
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
// Create a private project that the employee is not a member of
$privateProject = Project::factory()->forOrganization($data->organization)->create([
'is_public' => false,
]);
Passport::actingAs($data->user);
// Act
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
'ids' => [$timeEntry1->getKey(), $timeEntry2->getKey()],
'changes' => [
'project_id' => $privateProject->getKey(),
],
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['changes.project_id']);
// Verify the time entries were NOT updated in the database
$this->assertDatabaseMissing(TimeEntry::class, [
'id' => $timeEntry1->getKey(),
'project_id' => $privateProject->getKey(),
]);
$this->assertDatabaseMissing(TimeEntry::class, [
'id' => $timeEntry2->getKey(),
'project_id' => $privateProject->getKey(),
]);
}
public function test_update_multiple_remove_task_from_time_entries_only_if_project_is_set_to_a_new_value_without_setting_a_new_task(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$project1 = Project::factory()->forOrganization($data->organization)->create();
$project2 = Project::factory()->forOrganization($data->organization)->create();
@@ -3081,6 +3258,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$otherData = $this->createUserWithPermission();
$otherUser = User::factory()->create();
@@ -3138,6 +3316,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$otherData = $this->createUserWithPermission();
$otherUser = User::factory()->create();
@@ -3217,6 +3396,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry1 = TimeEntry::factory()->forMember($data->member)->create([
'description' => '',
@@ -3398,6 +3578,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -3432,6 +3613,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -3466,6 +3648,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -3500,6 +3683,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -3534,6 +3718,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -3570,6 +3755,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->travelTo($now);
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -3597,6 +3783,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -3820,6 +4007,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Tests\Unit\Filament\Resources;
use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use App\Models\Organization;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Facades\Config;
@@ -50,4 +52,149 @@ class TimeEntryResourceTest extends FilamentTestCase
// Assert
$response->assertSuccessful();
}
public function test_can_see_create_page_of_time_entry(): void
{
// Act
$response = Livewire::test(TimeEntryResource\Pages\CreateTimeEntry::class);
// Assert
$response->assertSuccessful();
}
public function test_can_create_time_entry(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()
->forOrganization($organization)
->forUser($user)
->create();
// Act
$response = Livewire::test(TimeEntryResource\Pages\CreateTimeEntry::class)
->fillForm([
'description' => 'Test time entry',
'billable' => true,
'start' => '2024-01-01 08:00:00',
'end' => '2024-01-01 10:00:00',
'member_id' => $member->getKey(),
])
->call('create')
->assertHasNoFormErrors();
// Assert
$response->assertSuccessful();
$timeEntry = TimeEntry::where('description', 'Test time entry')->first();
$this->assertNotNull($timeEntry);
$this->assertSame($member->getKey(), $timeEntry->member_id);
$this->assertSame($user->getKey(), $timeEntry->user_id);
$this->assertSame($organization->getKey(), $timeEntry->organization_id);
$this->assertTrue($timeEntry->billable);
}
public function test_can_create_time_entry_and_derives_user_and_organization_from_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()
->forOrganization($organization)
->forUser($user)
->create();
$otherUser = User::factory()->create();
$otherOrganization = Organization::factory()->create();
// Act
$response = Livewire::test(TimeEntryResource\Pages\CreateTimeEntry::class)
->fillForm([
'description' => 'Derived fields test',
'billable' => false,
'start' => '2024-03-01 09:00:00',
'end' => '2024-03-01 11:00:00',
'member_id' => $member->getKey(),
'user_id' => $otherUser->getKey(),
'organization_id' => $otherOrganization->getKey(),
])
->call('create')
->assertHasNoFormErrors();
// Assert
$response->assertSuccessful();
$timeEntry = TimeEntry::where('description', 'Derived fields test')->first();
$this->assertNotNull($timeEntry);
$this->assertSame($user->getKey(), $timeEntry->user_id);
$this->assertSame($organization->getKey(), $timeEntry->organization_id);
}
public function test_can_update_time_entry(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()
->forOrganization($organization)
->forUser($user)
->create();
$timeEntry = TimeEntry::factory()->forMember($member)->create();
// Act
$response = Livewire::test(TimeEntryResource\Pages\EditTimeEntry::class, ['record' => $timeEntry->getKey()])
->fillForm([
'description' => 'Updated description',
'billable' => true,
'start' => '2024-02-01 08:00:00',
'end' => '2024-02-01 12:00:00',
'member_id' => $member->getKey(),
])
->call('save')
->assertHasNoFormErrors();
// Assert
$response->assertSuccessful();
$timeEntry->refresh();
$this->assertSame('Updated description', $timeEntry->description);
$this->assertTrue($timeEntry->billable);
$this->assertSame($user->getKey(), $timeEntry->user_id);
$this->assertSame($organization->getKey(), $timeEntry->organization_id);
}
public function test_update_time_entry_derives_user_and_organization_from_new_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()
->forOrganization($organization)
->forUser($user)
->create();
$timeEntry = TimeEntry::factory()->create();
$newOrganization = Organization::factory()->create();
$newUser = User::factory()->create();
$newMember = Member::factory()
->forOrganization($newOrganization)
->forUser($newUser)
->create();
// Act
$response = Livewire::test(TimeEntryResource\Pages\EditTimeEntry::class, ['record' => $timeEntry->getKey()])
->fillForm([
'description' => 'Reassigned entry',
'billable' => false,
'start' => '2024-02-01 08:00:00',
'end' => '2024-02-01 12:00:00',
'member_id' => $newMember->getKey(),
])
->call('save')
->assertHasNoFormErrors();
// Assert
$response->assertSuccessful();
$timeEntry->refresh();
$this->assertSame($newMember->getKey(), $timeEntry->member_id);
$this->assertSame($newUser->getKey(), $timeEntry->user_id);
$this->assertSame($newOrganization->getKey(), $timeEntry->organization_id);
}
}

View File

@@ -1205,4 +1205,101 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
];
$this->assertEqualsCanonicalizing($expected, $result);
}
/**
* Test that rounding up does NOT add extra time when the entry is already on a 15-minute boundary.
* f.e. 13:00 - 14:30 (90 minutes) should stay at 90 minutes when rounding up with 15-minute interval.
*/
public function test_aggregate_time_round_up_does_not_add_time_when_already_on_boundary(): void
{
// Arrange
// Create a time entry with duration exactly on a 15-minute boundary (90 minutes = 5400 seconds)
// This simulates 13:00 - 14:30 (or any 90-minute entry)
$project = Project::factory()->create();
TimeEntry::factory()->startWithDuration(
Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 13:00:00'),
5400 // 90 minutes = 1 hour 30 minutes, exactly on 15-minute boundary
)->forProject($project)->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
null,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
TimeEntryRoundingType::Up,
15
);
// Assert
// The entry is already on a 15-minute boundary (90 minutes), so it should stay at 90 minutes (5400 seconds)
$this->assertEqualsCanonicalizing([
'seconds' => 5400, // 90 minutes - should NOT be rounded to 105 minutes (6300 seconds)
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 5400, // 90 minutes
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
], $result);
}
/**
* Test that rounding up works correctly for entries NOT on a boundary.
* Example: 13:00 - 13:48 (48 minutes) should round up to 13:00 - 14:00 (60 minutes).
*/
public function test_aggregate_time_round_up_works_when_not_on_boundary(): void
{
// Arrange
// Create a time entry with duration NOT on a 15-minute boundary (48 minutes = 2880 seconds)
$project = Project::factory()->create();
TimeEntry::factory()->startWithDuration(
Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 13:00:00'),
2880 // 48 minutes, not on 15-minute boundary
)->forProject($project)->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
null,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
TimeEntryRoundingType::Up,
15
);
// Assert
// 48 minutes rounded up to 15-minute interval = 60 minutes (3600 seconds)
$this->assertEqualsCanonicalizing([
'seconds' => 3600, // 60 minutes
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 3600, // 60 minutes
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
], $result);
}
}