Compare commits

..

13 Commits

Author SHA1 Message Date
Gregor Vostrak
a1c90a0fc5 make time entry create in calendar use minimal interval instead of 1h duration 2025-09-02 14:46:02 +02:00
Gregor Vostrak
7d2bb820ee make sure that 0 duration entries are shown correctly in calendar 2025-09-02 14:44:53 +02:00
Gregor Vostrak
62f5986b5f fix scroll overflow issue in calendar with banner 2025-09-02 13:51:40 +02:00
Gregor Vostrak
8d890bd21e improve calendar fetching behaviour to always include prev/next period 2025-09-01 17:21:43 +02:00
Gregor Vostrak
9785a6d848 make calendar fetch time ranges respect user timezone 2025-09-01 13:24:06 +02:00
Gregor Vostrak
181b8daac3 improve contrast of calendar events 2025-08-27 17:44:47 +02:00
Gregor Vostrak
ea8e5f6002 add edit time entry dropdown option to timeentryrow 2025-08-27 13:09:24 +02:00
Gregor Vostrak
7281ed5611 fix card background active color contrast in light mode 2025-08-15 23:16:34 +02:00
Gregor Vostrak
5fe64edbca fix recently tracked time entries card placeholders 2025-08-15 23:04:45 +02:00
Gregor Vostrak
84b7f3c7bd add support for week_start and time_format in calendar
also rename them so that they do not conflict with the datepicker calendar component
2025-08-14 16:46:41 +02:00
Gregor Vostrak
9ff794889f add calendar view 2025-08-14 16:25:12 +02:00
Gregor Vostrak
4b4df346da fix duplicated borders in time and detailed reporting view 2025-08-14 16:25:12 +02:00
Gregor Vostrak
9830fd6ce2 add timezone mismatch modal 2025-08-14 16:25:12 +02:00
42 changed files with 478 additions and 1238 deletions

View File

@@ -20,7 +20,6 @@ enum TimeEntryAggregationType: string
case Client = 'client';
case Billable = 'billable';
case Description = 'description';
case Tag = 'tag';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{

View File

@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class OverlappingTimeEntryApiException extends ApiException
{
public const string KEY = 'overlapping_time_entry';
}

View File

@@ -61,9 +61,6 @@ class OrganizationController extends Controller
if ($request->getTimeFormat() !== null) {
$organization->time_format = $request->getTimeFormat();
}
if ($request->getPreventOverlappingTimeEntries() !== null) {
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
}
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$oldBillableRate = $organization->billable_rate;

View File

@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
@@ -46,7 +45,6 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
@@ -58,43 +56,6 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller
{
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
{
if (! $organization->prevent_overlapping_time_entries) {
return;
}
$query = TimeEntry::query()
->where('organization_id', $organization->getKey())
->where('user_id', $member->user_id)
->when($exclude !== null, function (Builder $q) use ($exclude): void {
$q->where('id', '!=', $exclude->getKey());
})
->where(function (Builder $q) use ($start, $end): void {
$q->where(function (Builder $q2) use ($start): void {
$q2->where('end', '>', $start)
->where('start', '<', $start);
});
if ($end !== null) {
$q->orWhere(function (Builder $q4) use ($end): void {
$q4->where('start', '<', $end)
->where('end', '>', $end);
});
// Check if the new entry completely surrounds an existing entry
$q->orWhere(function (Builder $q6) use ($start, $end): void {
$q6->where('start', '>=', $start)
->where('end', '<=', $end);
});
}
});
if ($query->exists()) {
throw new OverlappingTimeEntryApiException;
}
}
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
{
parent::checkPermission($organization, $permission);
@@ -588,15 +549,17 @@ class TimeEntryController extends Controller
throw new TimeEntryStillRunningApiException;
}
// Overlap check for create
$start = Carbon::parse($request->input('start'));
$end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null;
$this->assertNoOverlap($organization, $member, $start, $end);
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
$client = $project?->client;
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
$timeEntry = new TimeEntry;
$timeEntry->fill($request->validated());
$timeEntry->client()->associate($client);
@@ -606,13 +569,6 @@ class TimeEntryController extends Controller
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
return new TimeEntryResource($timeEntry);
}
@@ -637,13 +593,6 @@ class TimeEntryController extends Controller
throw new TimeEntryCanNotBeRestartedApiException;
}
// Overlap check for update (exclude current)
/** @var Member $effectiveMember */
$effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member;
$effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start;
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;

View File

@@ -41,8 +41,7 @@ class HandleInertiaRequests extends Middleware
{
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
$hasServices = Module::has('Services') && Module::isEnabled('Services');
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
@@ -51,7 +50,6 @@ class HandleInertiaRequests extends Middleware
return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing,
'has_services_extension' => $hasServices,
'billing' => $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization),

View File

@@ -39,9 +39,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
'employees_can_see_billable_rates' => [
'boolean',
],
'prevent_overlapping_time_entries' => [
'boolean',
],
'number_format' => [
Rule::enum(NumberFormat::class),
],
@@ -101,9 +98,4 @@ class OrganizationUpdateRequest extends BaseFormRequest
{
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
}
public function getPreventOverlappingTimeEntries(): ?bool
{
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
}
}

View File

@@ -53,8 +53,6 @@ class OrganizationResource extends BaseResource
'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 */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */

View File

@@ -70,7 +70,6 @@ class Organization extends JetstreamTeam implements AuditableContract
'personal_team' => 'boolean',
'currency' => 'string',
'employees_can_see_billable_rates' => 'boolean',
'prevent_overlapping_time_entries' => 'boolean',
'number_format' => NumberFormat::class,
'currency_format' => CurrencyFormat::class,
'date_format' => DateFormat::class,

View File

@@ -10,7 +10,6 @@ use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
@@ -18,7 +17,6 @@ use Carbon\CarbonTimeZone;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TimeEntryAggregationService
@@ -47,21 +45,9 @@ class TimeEntryAggregationService
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
{
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
/** @var Builder<TimeEntry> $baseTotalsQuery */
$baseTotalsQuery = $timeEntriesQuery->clone();
$group1Select = null;
$group2Select = null;
$groupBy = null;
// If any grouping is by tag, expand rows per tag and ensure a NULL row for entries without tags
if (($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag)) {
$timeEntriesQuery->crossJoin(DB::raw(
"LATERAL (\n".
" SELECT jsonb_array_elements_text(coalesce(tags, '[]'::jsonb)) AS tag\n".
" UNION ALL\n".
" SELECT ''::text AS tag WHERE coalesce(jsonb_array_length(tags), 0) = 0\n".
') AS tag(tag)'
));
}
if ($group1Type !== null) {
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
$groupBy = ['group_1'];
@@ -98,26 +84,6 @@ class TimeEntryAggregationService
$group1Response = [];
$group1ResponseSum = 0;
$group1ResponseCost = 0;
// If Tag is subgroup, prepare base totals per primary group without tag expansion
$baseTotalsPerGroup1Map = [];
if ($group2Type === TimeEntryAggregationType::Tag) {
$baseTotalsPerGroup1Query = $baseTotalsQuery->clone();
$baseTotalsPerGroup1 = $baseTotalsPerGroup1Query
->selectRaw(
$group1Select.' as group_1,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
)
->groupBy('group_1')
->get();
foreach ($baseTotalsPerGroup1 as $row) {
/** @var object{group_1: mixed, aggregate: int|null, cost: int|null} $row */
$baseTotalsPerGroup1Map[(string) ($row->group_1 ?? '')] = [
'aggregate' => (int) ($row->aggregate ?? 0),
'cost' => (int) ($row->cost ?? 0),
];
}
}
foreach ($groupedAggregates as $group1 => $group1Aggregates) {
/** @var string|int $group1 */
$group2Response = [];
@@ -137,14 +103,6 @@ class TimeEntryAggregationService
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
$group2ResponseCost += (int) $aggregate->get(0)->cost;
}
// Override primary group totals when Tag is subgroup to avoid double counting
if ($group2Type === TimeEntryAggregationType::Tag) {
$keyForMap = (string) $group1;
if (array_key_exists($keyForMap, $baseTotalsPerGroup1Map)) {
$group2ResponseSum = $baseTotalsPerGroup1Map[$keyForMap]['aggregate'];
$group2ResponseCost = $baseTotalsPerGroup1Map[$keyForMap]['cost'];
}
}
} else {
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
@@ -163,23 +121,6 @@ class TimeEntryAggregationService
$group1ResponseCost += $group2ResponseCost;
}
// If Tag is selected in any grouping, compute overall totals from base (non-tag-expanded) query to avoid double counting
$hasTagGrouping = ($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag);
if ($hasTagGrouping) {
// Reset selects and ordering on the cloned base query
$baseTotals = $baseTotalsQuery
->selectRaw(
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
)
->first();
if ($baseTotals !== null) {
/** @var object{aggregate: int|null, cost: int|null} $baseTotals */
$group1ResponseSum = (int) ($baseTotals->aggregate ?? 0);
$group1ResponseCost = (int) ($baseTotals->cost ?? 0);
}
}
if ($fillGapsInTimeGroupsIsPossible) {
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
}
@@ -353,17 +294,6 @@ class TimeEntryAggregationService
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Tag) {
$tags = Tag::query()
->whereIn('id', $keys)
->select('id', 'name')
->get();
foreach ($tags as $tag) {
$descriptorMap[$tag->id] = [
'description' => $tag->name,
'color' => null,
];
}
}
return $descriptorMap;
@@ -506,8 +436,6 @@ class TimeEntryAggregationService
return 'billable';
} elseif ($group === TimeEntryAggregationType::Description) {
return 'description';
} elseif ($group === TimeEntryAggregationType::Tag) {
return 'tag';
}
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->boolean('prevent_overlapping_time_entries')->default(false)->after('employees_can_see_billable_rates');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('prevent_overlapping_time_entries');
});
}
};

View File

@@ -2,7 +2,7 @@
# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically
/usr/bin/mc alias set local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
/usr/bin/mc config host add local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
/usr/bin/mc rm -r --force local/${S3_BUCKET};
/usr/bin/mc mb --ignore-existing local/${S3_BUCKET};
/usr/bin/mc anonymous set public local/${S3_BUCKET};

View File

@@ -16,7 +16,7 @@ RUN CGO_ENABLED=1 \
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
CGO_CFLAGS=$(php-config --includes) \
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
xcaddy build v2.10.0 \
xcaddy build \
--output /usr/local/bin/frankenphp \
--with github.com/dunglas/frankenphp=./ \
--with github.com/dunglas/frankenphp/caddy=./caddy/ \

View File

@@ -9,10 +9,7 @@ async function goToOrganizationSettings(page) {
async function createTimeEntry(page, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill('Test time entry');

View File

@@ -26,10 +26,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
// Then create the time entry
await goToTimeOverview(page);
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page
@@ -55,10 +52,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
await goToTimeOverview(page);
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page
@@ -87,10 +81,7 @@ async function createTimeEntryWithBillableStatus(
duration: string
) {
await goToTimeOverview(page);
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page

View File

@@ -14,7 +14,6 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
@@ -48,7 +47,6 @@ return [
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',
],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
];

View File

@@ -203,7 +203,6 @@ return [
'organization' => 'The :attribute does not exist.',
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
'project_name_already_exists' => 'A project with the same name and client already exists in the organization.',
'overlapping_time_entry' => 'Overlapping time entries are not allowed.',
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
'client_name_already_exists' => 'A client with the same name already exists in the organization.',
'task_name_already_exists' => 'A task with the same name already exists in the project.',

View File

@@ -113,7 +113,7 @@ const option = computed(() => ({
},
axisLabel: {
fontSize: 12,
fontWeight: 400,
fontWeight: 600,
color: labelColor.value,
margin: 16,
fontFamily: 'Inter, sans-serif',

View File

@@ -30,7 +30,10 @@ const organization = inject<ComputedRef<Organization>>('organization');
<template>
<div
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
<div :class="twMerge('pl-6 flex items-center space-x-3', props.indent ? 'pl-16' : '')">
<div
:class="
twMerge('pl-6 font-medium flex items-center space-x-3', props.indent ? 'pl-16' : '')
">
<GroupedItemsCountButton
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
:expanded="expanded"

View File

@@ -27,10 +27,9 @@ onMounted(() => {
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
userTimezone.value = getUserTimezone();
const now = getDayJsInstance()();
if (
now.tz(timezone.value).format() !== now.tz(userTimezone.value).format() &&
getDayJsInstance()().tz(timezone.value).format() !==
getDayJsInstance()().tz(userTimezone.value).format() &&
!hideTimezoneMismatchModal.value
) {
show.value = true;

View File

@@ -1,7 +1,7 @@
<template>
<div
aria-live="assertive"
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 z-[70]">
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 sm:pb-24 z-[70]">
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
<Notification
v-for="notification in notifications"

View File

@@ -1,23 +1,12 @@
<script setup lang="ts">
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import { Link, usePage } from '@inertiajs/vue3';
import {
Cog6ToothIcon,
PlusCircleIcon,
CheckCircleIcon,
ArrowRightIcon,
} from '@heroicons/vue/24/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import { usePage } from '@inertiajs/vue3';
import type { Organization, User } from '@/types/models';
import { isBillingActivated } from '@/utils/billing';
import { canManageBilling } from '@/utils/permissions';
import { switchOrganization } from '@/utils/useOrganization';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
} from '@/Components/ui/dropdown-menu';
const page = usePage<{
jetstream: {
@@ -39,79 +28,84 @@ const switchToTeam = (organization: Organization) => {
</script>
<template>
<DropdownMenu v-if="page.props.jetstream.hasTeamFeatures">
<DropdownMenuTrigger
class="flex w-full text-left hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-ring cursor-pointer transition pl-2 py-1 rounded w-full items-center justify-between"
as-child>
<button data-testid="organization_switcher">
<Dropdown v-if="page.props.jetstream.hasTeamFeatures" align="center" width="60">
<template #trigger>
<div
data-testid="organization_switcher"
class="flex hover:bg-white/10 cursor-pointer transition px-2 py-1 rounded-lg w-full items-center justify-between font-medium">
<div class="flex flex-1 space-x-2 items-center w-[calc(100%-30px)]">
<div
class="rounded bg-blue-900 font-medium text-xs flex-shrink-0 text-white w-5 h-5 flex items-center justify-center">
class="rounded sm:rounded-lg bg-blue-900 font-semibold text-xs sm:text-sm flex-shrink-0 text-white w-5 sm:w-6 h-5 sm:h-6 flex items-center justify-center">
{{ page.props.auth.user.current_team.name.slice(0, 1).toUpperCase() }}
</div>
<span class="text-xs flex-1 truncate font-medium">
<span class="text-sm flex-1 truncate font-semibold">
{{ page.props.auth.user.current_team.name }}
</span>
</div>
<div class="w-[30px]">
<div class="p-1 rounded-full flex items-center w-6 h-6">
<ChevronDownIcon class="w-4 sm:w-full mt-[1px]"></ChevronDownIcon>
</div>
<button
class="p-1 transition hover:bg-white/10 rounded-full flex items-center w-8 h-8">
<ChevronDownIcon class="w-5 sm:w-full mt-[1px]"></ChevronDownIcon>
</button>
</div>
</button>
</DropdownMenuTrigger>
</div>
</template>
<DropdownMenuContent align="start">
<template #content>
<div class="w-60">
<DropdownMenuLabel>Manage Organization</DropdownMenuLabel>
<!-- Organization Management -->
<div class="block px-4 py-2 text-xs text-text-secondary">Manage Organization</div>
<DropdownMenuItem as-child>
<Link
:href="route('teams.show', page.props.auth.user.current_team.id)"
class="inline-flex items-center gap-2.5 w-full">
<Cog6ToothIcon class="w-5 h-5 text-icon-default" />
<span>Organization Settings</span>
</Link>
</DropdownMenuItem>
<!-- Organization Settings -->
<DropdownLink :href="route('teams.show', page.props.auth.user.current_team.id)">
Organization Settings
</DropdownLink>
<DropdownMenuItem v-if="canManageBilling() && isBillingActivated()" as-child>
<Link href="/billing" class="inline-flex items-center w-full"> Billing </Link>
</DropdownMenuItem>
<DropdownLink v-if="canManageBilling() && isBillingActivated()" href="/billing">
Billing
</DropdownLink>
<DropdownMenuItem v-if="page.props.jetstream.canCreateTeams" as-child>
<Link
:href="route('teams.create')"
class="inline-flex items-center gap-2.5 w-full">
<PlusCircleIcon class="w-5 h-5 text-icon-default" />
<span>Create new organization</span>
</Link>
</DropdownMenuItem>
<DropdownLink
v-if="page.props.jetstream.canCreateTeams"
:href="route('teams.create')">
Create new organization
</DropdownLink>
<!-- Organization Switcher -->
<template v-if="page.props.auth.user.all_teams.length > 1">
<div class="border-t border-card-background-separator" />
<DropdownMenuLabel>Switch Organizations</DropdownMenuLabel>
<div class="block px-4 py-2 text-xs text-text-secondary">
Switch Organizations
</div>
<template v-for="team in page.props.auth.user.all_teams" :key="team.id">
<form @submit.prevent="switchToTeam(team)">
<DropdownMenuItem
as-child
class="inline-flex gap-2.5 items-center w-full">
<button type="submit">
<CheckCircleIcon
<DropdownLink as="button">
<div class="flex items-center">
<svg
v-if="team.id == page.props.auth.user.current_team_id"
class="h-5 w-5 text-green-400" />
<ArrowRightIcon v-else class="h-5 w-5 text-icon-default" />
class="me-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="w-full truncate text-left">
<div>
{{ team.name }}
</div>
</button>
</DropdownMenuItem>
</div>
</DropdownLink>
</form>
</template>
</template>
</div>
</DropdownMenuContent>
</DropdownMenu>
</template>
</Dropdown>
</template>

View File

@@ -16,25 +16,12 @@ import { useProjectsStore } from '@/utils/useProjects';
import { useTasksStore } from '@/utils/useTasks';
import { useTagsStore } from '@/utils/useTags';
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
import type {
CreateClientBody,
CreateProjectBody,
CreateTimeEntryBody,
Project,
Tag,
} from '@/packages/api/src';
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
import TimeTrackerMoreOptionsDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
import { ref } from 'vue';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification';
const page = usePage<{
auth: {
@@ -60,8 +47,6 @@ const emit = defineEmits<{
change: [];
}>();
const showManualTimeEntryModal = ref(false);
watch(isActive, () => {
if (isActive.value) {
startLiveTimer();
@@ -108,64 +93,14 @@ function switchToTimeEntryOrganization() {
switchOrganization(currentTimeEntry.value.organization_id);
}
}
async function createTag(tag: string): Promise<Tag | undefined> {
async function createTag(tag: string) {
return await useTagsStore().createTag(tag);
}
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
await useTimeEntriesStore().createTimeEntry(timeEntry);
showManualTimeEntryModal.value = false;
}
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
const deleteTimeEntryMutation = useMutation({
mutationFn: async (timeEntryId: string) => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) {
throw new Error('No organization selected');
}
return await api.deleteTimeEntry(undefined, {
params: {
organization: organizationId,
timeEntry: timeEntryId,
},
});
},
onSuccess: async () => {
await currentTimeEntryStore.fetchCurrentTimeEntry();
await useTimeEntriesStore().fetchTimeEntries();
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
},
});
async function discardCurrentTimeEntry() {
if (currentTimeEntry.value.id) {
await handleApiRequestNotifications(
() => deleteTimeEntryMutation.mutateAsync(currentTimeEntry.value.id),
'Time entry discarded successfully',
'Failed to discard time entry'
);
}
}
const { tags } = storeToRefs(useTagsStore());
</script>
<template>
<TimeEntryCreateModal
v-model:show="showManualTimeEntryModal"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:create-project="createProject"
:create-client="createClient"
:create-tag="createTag"
:create-time-entry="createTimeEntry"
:projects
:tasks
:tags
:clients></TimeEntryCreateModal>
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
<div class="relative">
<TimeTrackerRunningInDifferentOrganizationOverlay
@@ -174,34 +109,24 @@ const { tags } = storeToRefs(useTagsStore());
switchToTimeEntryOrganization
"></TimeTrackerRunningInDifferentOrganizationOverlay>
<div class="flex w-full items-center gap-2">
<div class="flex w-full items-center gap-2">
<div class="flex-1">
<TimeTrackerControls
v-model:current-time-entry="currentTimeEntry"
v-model:live-timer="now"
:create-project
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:can-create-project="canCreateProjects()"
:create-client
:clients
:tags
:tasks
:projects
:create-tag
:is-active
:currency="getOrganizationCurrencyString()"
@start-live-timer="startLiveTimer"
@stop-live-timer="stopLiveTimer"
@start-timer="setActiveState(true)"
@stop-timer="setActiveState(false)"
@update-time-entry="updateTimeEntry"></TimeTrackerControls>
</div>
<TimeTrackerMoreOptionsDropdown
:has-active-timer="isActive"
@manual-entry="showManualTimeEntryModal = true"
@discard="discardCurrentTimeEntry"></TimeTrackerMoreOptionsDropdown>
</div>
</div>
<TimeTrackerControls
v-model:current-time-entry="currentTimeEntry"
v-model:live-timer="now"
:create-project
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:can-create-project="canCreateProjects()"
:create-client
:clients
:tags
:tasks
:projects
:create-tag
:is-active
:currency="getOrganizationCurrencyString()"
@start-live-timer="startLiveTimer"
@stop-live-timer="stopLiveTimer"
@start-timer="setActiveState(true)"
@stop-timer="setActiveState(false)"
@update-time-entry="updateTimeEntry"></TimeTrackerControls>
</div>
</template>

View File

@@ -1,24 +1,10 @@
<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import { router, usePage } from '@inertiajs/vue3';
import type { Organization, User } from '@/types/models';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
} from '@/Components/ui/dropdown-menu';
import {
UserCircleIcon,
KeyIcon,
ArrowLeftOnRectangleIcon,
ChatBubbleLeftRightIcon,
} from '@heroicons/vue/24/solid';
import { openFeedback } from '@/utils/feedback';
import DropdownLink from '@/Components/DropdownLink.vue';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
const page = usePage<{
has_services_extension?: boolean;
has_billing_extension?: boolean;
jetstream: {
canCreateTeams: boolean;
hasTeamFeatures: boolean;
@@ -37,58 +23,60 @@ const logout = () => {
};
</script>
<template>
<div class="relative">
<DropdownMenu>
<DropdownMenuTrigger
class="flex text-sm border-2 outline-none border-transparent rounded-full focus-visible:ring-2 focus-visible:ring-ring transition"
as-child>
<button data-testid="current_user_button">
<div class="ms-3 relative">
<Dropdown align="center" width="48">
<template #trigger>
<button
v-if="page.props.jetstream.managesProfilePhotos"
data-testid="current_user_button"
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
<img
class="h-7 w-7 rounded-full object-cover"
class="h-8 w-8 rounded-full object-cover"
:src="page.props.auth.user.profile_photo_url"
:alt="page.props.auth.user.name" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" class="max-w-48">
<DropdownMenuLabel>Manage Account</DropdownMenuLabel>
<DropdownMenuItem as-child>
<Link
:href="route('profile.show')"
class="inline-flex items-center gap-2.5 w-full">
<UserCircleIcon class="w-5 h-5 text-icon-default" />
<span>Profile Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem v-if="page.props.jetstream.hasApiFeatures" as-child>
<Link
:href="route('api-tokens.index')"
class="inline-flex items-center gap-2.5 w-full">
<KeyIcon class="w-5 h-5 text-icon-default" />
<span>API Tokens</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem v-if="page.props.has_services_extension" as-child>
<span v-else class="inline-flex rounded-md">
<button
type="button"
class="inline-flex items-center gap-2.5 w-full"
@click="openFeedback">
<ChatBubbleLeftRightIcon class="w-5 h-5 text-icon-default" />
<span>Feedback</span>
</button>
</DropdownMenuItem>
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150">
{{ page.props.auth.user.name }}
<form class="w-full" @submit.prevent="logout">
<DropdownMenuItem as-child class="inline-flex items-center gap-2.5 w-full">
<button type="submit" data-testid="logout_button">
<ArrowLeftOnRectangleIcon class="w-5 h-5 text-icon-default" />
<span>Log Out</span>
</button>
</DropdownMenuItem>
<svg
class="ms-2 -me-0.5 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
</span>
</template>
<template #content>
<!-- Account Management -->
<div class="block px-4 py-2 text-xs text-gray-400">Manage Account</div>
<DropdownLink :href="route('profile.show')"> Profile </DropdownLink>
<DropdownLink
v-if="page.props.jetstream.hasApiFeatures"
:href="route('api-tokens.index')">
API Tokens
</DropdownLink>
<div class="border-t border-card-border" />
<!-- Authentication -->
<form @submit.prevent="logout">
<DropdownLink as="button" data-testid="logout_button"> Log Out </DropdownLink>
</form>
</DropdownMenuContent>
</DropdownMenu>
</template>
</Dropdown>
</div>
</template>

View File

@@ -19,7 +19,7 @@ const forwardedProps = useForwardProps(delegatedProps);
<template>
<DropdownMenuLabel
v-bind="forwardedProps"
:class="cn('block px-2 py-2 text-xs text-gray-400', inset && 'pl-8', props.class)">
:class="cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)">
<slot />
</DropdownMenuLabel>
</template>

View File

@@ -47,8 +47,6 @@ import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import { twMerge } from 'tailwind-merge';
import Button from '@/Components/ui/button/Button.vue';
import { openFeedback } from '@/utils/feedback';
defineProps({
title: String,
@@ -96,8 +94,8 @@ onMounted(async () => {
}, 100);
};
});
const page = usePage<{
has_services_extension?: boolean;
auth: {
user: User;
};
@@ -108,7 +106,7 @@ const page = usePage<{
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
<div
:class="{
'!flex bg-default-background w-full z-30': showSidebarMenu,
'!flex bg-default-background w-full z-[9999999999]': showSidebarMenu,
}"
class="flex-shrink-0 h-screen hidden fixed w-[230px] 2xl:w-[250px] px-2.5 2xl:px-3 py-4 lg:flex flex-col justify-between">
<div class="flex flex-col h-full">
@@ -244,23 +242,14 @@ const page = usePage<{
<div class="justify-self-end">
<UpdateSidebarNotification></UpdateSidebarNotification>
<ul
class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center">
<UserSettingsIcon></UserSettingsIcon>
class="border-t border-default-background-separator pt-3 flex justify-between pr-4 items-center">
<NavigationSidebarItem
class="flex-1"
title="Profile Settings"
:icon="Cog6ToothIcon"
:href="route('profile.show')"></NavigationSidebarItem>
<Button
v-if="page.props.has_services_extension"
variant="outline"
size="xs"
class="rounded-full ml-2 flex h-6 w-6 items-center text-xs text-icon-default justify-center"
@click="openFeedback">
?
</Button>
<UserSettingsIcon></UserSettingsIcon>
</ul>
</div>
</div>

View File

@@ -400,7 +400,6 @@ async function downloadExport(format: ExportFormat) {
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
:delete-time-entry="() => deleteTimeEntries([entry])"
:currency="getOrganizationCurrencyString()"
:duplicate-time-entry="() => createTimeEntry(entry)"
:members="members"
show-date
show-member

View File

@@ -27,7 +27,7 @@ interface FormValues {
}
const store = useOrganizationStore();
const { updateOrganization } = store;
const { fetchOrganization, updateOrganization } = store;
const { organization } = storeToRefs(store);
const queryClient = useQueryClient();
@@ -47,6 +47,7 @@ const mutation = useMutation({
});
onMounted(async () => {
await fetchOrganization();
if (organization.value) {
form.value = {
number_format: organization.value.number_format as NumberFormat,

View File

@@ -1,68 +0,0 @@
<script setup lang="ts">
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { Checkbox } from '@/packages/ui/src';
import type { UpdateOrganizationBody } from '@/packages/api/src';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
const store = useOrganizationStore();
const { updateOrganization } = store;
const { organization } = storeToRefs(store);
const queryClient = useQueryClient();
const form = ref<{ prevent_overlapping_time_entries: boolean }>({
prevent_overlapping_time_entries: false,
});
onMounted(async () => {
form.value.prevent_overlapping_time_entries =
organization.value?.prevent_overlapping_time_entries ?? false;
});
const mutation = useMutation({
mutationFn: (values: Partial<UpdateOrganizationBody>) => updateOrganization(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organization'] });
},
});
async function submit() {
await mutation.mutateAsync({
prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries,
});
}
</script>
<template>
<FormSection>
<template #title>Time Entry Settings</template>
<template #description>
Disallow overlapping time entries for members of this organization. When enabled, users
cannot create new time entries that overlap with their existing ones. This only affects
newly created entries.
</template>
<template #form>
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<div class="flex items-center space-x-2">
<Checkbox
id="preventOverlappingTimeEntries"
v-model:checked="form.prevent_overlapping_time_entries" />
<InputLabel
for="preventOverlappingTimeEntries"
value="Prevent overlapping time entries (new entries only)" />
</div>
</div>
</div>
</template>
<template #actions>
<PrimaryButton :disabled="mutation.isPending.value" @click="submit">Save</PrimaryButton>
</template>
</FormSection>
</template>

View File

@@ -8,25 +8,12 @@ import type { Permissions, Role } from '@/types/jetstream';
import { canUpdateOrganization } from '@/utils/permissions';
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
import { onMounted, ref } from 'vue';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
defineProps<{
team: Organization;
availableRoles: Role[];
permissions: Permissions;
}>();
const loading = ref(true);
const orgStore = useOrganizationStore();
const { organization } = storeToRefs(orgStore);
onMounted(async () => {
await orgStore.fetchOrganization();
loading.value = false;
});
</script>
<template>
@@ -39,25 +26,17 @@ onMounted(async () => {
<div>
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
<div v-if="loading || !organization" class="py-16 text-center text-text-secondary">
Loading organization settings...
</div>
<template v-else>
<UpdateTeamNameForm :team="team" :permissions="permissions" />
<UpdateTeamNameForm :team="team" :permissions="permissions" />
<SectionBorder />
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
<SectionBorder />
<SectionBorder />
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
<SectionBorder />
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
<SectionBorder />
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
<SectionBorder />
<OrganizationTimeEntrySettings v-if="canUpdateOrganization()" />
<SectionBorder />
<template v-if="permissions.canDeleteTeam && !team.personal_team">
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
</template>
<template v-if="permissions.canDeleteTeam && !team.personal_team">
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
</template>
</div>
</div>

View File

@@ -15,6 +15,8 @@ import type {
} from '@/packages/api/src';
import { useElementVisibility } from '@vueuse/core';
import { ClockIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { useTasksStore } from '@/utils/useTasks';
@@ -22,6 +24,7 @@ import { useProjectsStore } from '@/utils/useProjects';
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
import { useTagsStore } from '@/utils/useTags';
import { useClientsStore } from '@/utils/useClients';
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
@@ -70,6 +73,7 @@ onMounted(async () => {
await timeEntriesStore.fetchTimeEntries();
});
const showManualTimeEntryModal = ref(false);
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
@@ -101,9 +105,33 @@ function deleteSelected() {
</script>
<template>
<TimeEntryCreateModal
v-model:show="showManualTimeEntryModal"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:create-project="createProject"
:create-client="createClient"
:create-tag="createTag"
:create-time-entry="createTimeEntry"
:projects
:tasks
:tags
:clients></TimeEntryCreateModal>
<AppLayout title="Dashboard" data-testid="time_view">
<MainContainer class="pt-5 lg:pt-8 pb-4 lg:pb-6">
<TimeTracker></TimeTracker>
<div
class="lg:flex items-end lg:divide-x divide-default-background-separator divide-y lg:divide-y-0 space-y-2 lg:space-y-0 lg:space-x-2">
<div class="flex-1">
<TimeTracker></TimeTracker>
</div>
<div class="pb-2 pt-2 lg:pt-0 lg:pl-4 flex justify-center">
<SecondaryButton
class="w-full text-center flex justify-center"
:icon="PlusIcon"
@click="showManualTimeEntryModal = true"
>Manual time entry
</SecondaryButton>
</div>
</div>
</MainContainer>
<TimeEntryMassActionRow
:selected-time-entries="selectedTimeEntries"

View File

@@ -36,14 +36,20 @@ const ClientResource = z
const ClientCollection = z.array(ClientResource);
const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
const ClientUpdateRequest = z
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
.object({
name: z.string().min(1).max(255),
is_archived: z.boolean().optional(),
})
.passthrough();
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
const InvitationResource = z
.object({ id: z.string(), email: z.string(), role: z.string() })
.passthrough();
const InvitationStoreRequest = z
.object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) })
.object({
email: z.string().email(),
role: z.enum(['admin', 'manager', 'employee']),
})
.passthrough();
const InvoiceResource = z
.object({
@@ -91,7 +97,6 @@ const InvoiceStoreRequest = z
billing_period_end: z.union([z.string(), z.null()]).optional(),
reference: z.string(),
currency: z.string(),
payment_iban: z.union([z.string(), z.null()]).optional(),
tax_rate: z.number().int().gte(0).lte(2147483647).optional(),
discount_amount: z.number().int().gte(0).lte(9223372036854776000).optional(),
discount_type: InvoiceDiscountType.optional(),
@@ -156,7 +161,6 @@ const DetailedInvoiceResource = z
discount_type: z.string(),
discount_amount: z.number().int(),
tax_rate: z.number().int(),
payment_iban: z.string(),
status: z.string(),
currency: z.string(),
date: z.string(),
@@ -202,7 +206,6 @@ const InvoiceUpdateRequest = z
billing_period_end: z.union([z.string(), z.null()]),
reference: z.string(),
currency: z.string(),
payment_iban: z.union([z.string(), z.null()]),
tax_rate: z.number().int().gte(0).lte(2147483647),
discount_amount: z.number().int().gte(0).lte(9223372036854776000),
discount_type: InvoiceDiscountType,
@@ -317,7 +320,6 @@ const OrganizationResource = z
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
prevent_overlapping_time_entries: z.boolean(),
currency: z.string(),
currency_symbol: z.string(),
number_format: NumberFormat,
@@ -332,7 +334,6 @@ const OrganizationUpdateRequest = z
name: z.string().max(255),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
prevent_overlapping_time_entries: z.boolean(),
number_format: NumberFormat,
currency_format: CurrencyFormat,
date_format: DateFormat,
@@ -387,7 +388,10 @@ const ProjectMemberResource = z
})
.passthrough();
const ProjectMemberStoreRequest = z
.object({ member_id: z.string(), billable_rate: z.union([z.number(), z.null()]).optional() })
.object({
member_id: z.string(),
billable_rate: z.union([z.number(), z.null()]).optional(),
})
.passthrough();
const ProjectMemberUpdateRequest = z
.object({ billable_rate: z.union([z.number(), z.null()]) })
@@ -416,7 +420,6 @@ const TimeEntryAggregationType = z.enum([
'client',
'billable',
'description',
'tag',
]);
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
const Weekday = z.enum([
@@ -428,7 +431,6 @@ const Weekday = z.enum([
'saturday',
'sunday',
]);
const TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']);
const ReportStoreRequest = z
.object({
name: z.string().max(255),
@@ -451,8 +453,6 @@ const ReportStoreRequest = z
history_group: TimeEntryAggregationTypeInterval,
week_start: Weekday.optional(),
timezone: z.union([z.string(), z.null()]).optional(),
rounding_type: TimeEntryRoundingType.optional(),
rounding_minutes: z.union([z.number(), z.null()]).optional(),
})
.passthrough(),
})
@@ -479,8 +479,6 @@ const DetailedReportResource = z
project_ids: z.union([z.array(z.string()), z.null()]),
tag_ids: z.union([z.array(z.string()), z.null()]),
task_ids: z.union([z.array(z.string()), z.null()]),
rounding_type: z.union([z.string(), z.null()]),
rounding_minutes: z.union([z.number(), z.null()]),
})
.passthrough(),
created_at: z.string(),
@@ -594,7 +592,12 @@ const DetailedWithDataReportResource = z
})
.passthrough();
const TagResource = z
.object({ id: z.string(), name: z.string(), created_at: z.string(), updated_at: z.string() })
.object({
id: z.string(),
name: z.string(),
created_at: z.string(),
updated_at: z.string(),
})
.passthrough();
const TagCollection = z.array(TagResource);
const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
@@ -626,7 +629,6 @@ const TaskUpdateRequest = z
})
.passthrough();
const start = z.union([z.string(), z.null()]).optional();
const rounding_minutes = z.union([z.number(), z.null()]).optional();
const TimeEntryResource = z
.object({
id: z.string(),
@@ -747,7 +749,6 @@ export const schemas = {
TimeEntryAggregationType,
TimeEntryAggregationTypeInterval,
Weekday,
TimeEntryRoundingType,
ReportStoreRequest,
DetailedReportResource,
ReportUpdateRequest,
@@ -760,7 +761,6 @@ export const schemas = {
TaskStoreRequest,
TaskUpdateRequest,
start,
rounding_minutes,
TimeEntryResource,
TimeEntryStoreRequest,
TimeEntryUpdateMultipleRequest,
@@ -790,7 +790,13 @@ const endpoints = makeApi([
alias: 'getCurrencies',
requestFormat: 'json',
response: z.array(
z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough()
z
.object({
code: z.string(),
name: z.string(),
symbol: z.string(),
})
.passthrough()
),
},
{
@@ -862,7 +868,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1157,7 +1166,13 @@ const endpoints = makeApi([
},
],
response: z.array(
z.object({ value: z.number().int(), name: z.string(), color: z.string() }).passthrough()
z
.object({
value: z.number().int(),
name: z.string(),
color: z.string(),
})
.passthrough()
),
errors: [
{
@@ -1220,7 +1235,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1263,7 +1281,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1311,7 +1332,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1339,7 +1363,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -1377,7 +1405,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -1433,7 +1465,7 @@ const endpoints = makeApi([
status: 400,
schema: z.union([
z.object({ message: z.string() }).passthrough(),
z.object({ message: z.literal('Invalid base64 encoded data') }).passthrough(),
z.object({ message: z.string() }).passthrough(),
]),
},
{
@@ -1455,7 +1487,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1476,7 +1511,11 @@ const endpoints = makeApi([
.object({
data: z.array(
z
.object({ key: z.string(), name: z.string(), description: z.string() })
.object({
key: z.string(),
name: z.string(),
description: z.string(),
})
.passthrough()
),
})
@@ -1564,7 +1603,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1592,7 +1634,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -1614,7 +1660,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1760,7 +1809,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1803,7 +1855,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1846,7 +1901,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1930,7 +1988,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1995,13 +2056,6 @@ const endpoints = makeApi([
],
response: z.object({ download_link: z.string() }).passthrough(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
@@ -2021,7 +2075,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2045,13 +2102,6 @@ const endpoints = makeApi([
],
response: z.object({ download_link: z.string() }).passthrough(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
@@ -2097,7 +2147,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2119,7 +2173,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2189,7 +2246,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2222,7 +2282,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2244,7 +2308,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2277,7 +2344,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2299,7 +2370,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2327,7 +2401,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2370,7 +2448,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2433,7 +2515,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2549,7 +2634,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2592,7 +2680,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2676,7 +2767,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2704,7 +2798,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2820,7 +2918,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2842,7 +2944,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2948,7 +3053,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3032,7 +3140,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3142,7 +3253,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3190,7 +3304,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3218,7 +3335,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -3313,7 +3434,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3356,7 +3480,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3404,7 +3531,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3432,7 +3562,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -3505,16 +3639,6 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{
name: 'user_id',
type: 'Query',
@@ -3572,7 +3696,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3600,7 +3727,11 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -3622,7 +3753,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3665,7 +3799,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3708,7 +3845,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3741,7 +3881,11 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -3763,7 +3907,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3833,7 +3980,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client',
'billable',
'description',
'tag',
])
.optional(),
},
@@ -3852,7 +3998,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client',
'billable',
'description',
'tag',
])
.optional(),
},
@@ -3891,16 +4036,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{
name: 'member_ids',
type: 'Query',
@@ -3985,7 +4120,10 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -4020,7 +4158,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client',
'billable',
'description',
'tag',
]),
},
{
@@ -4037,7 +4174,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client',
'billable',
'description',
'tag',
]),
},
{
@@ -4085,16 +4221,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{
name: 'member_ids',
type: 'Query',
@@ -4130,7 +4256,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -4152,7 +4282,10 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -4213,16 +4346,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{
name: 'member_ids',
type: 'Query',
@@ -4253,7 +4376,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -4275,7 +4402,10 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -4357,7 +4487,11 @@ Please note that the access token is only shown in this response and cannot be r
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -4374,7 +4508,10 @@ Please note that the access token is only shown in this response and cannot be r
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -4397,7 +4534,11 @@ Please note that the access token is only shown in this response and cannot be r
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -4435,7 +4576,11 @@ Please note that the access token is only shown in this response and cannot be r
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{

View File

@@ -2,10 +2,9 @@
import { computed, inject, type ComputedRef } from 'vue';
import { formatDate, formatHumanReadableDuration } from '../utils/time';
import type { Organization } from '@/packages/api/src';
import type { Dayjs } from 'dayjs';
const props = defineProps<{
date: Dayjs;
date: Date;
totalMinutes?: number;
}>();
@@ -21,7 +20,7 @@ const dateFormat = computed(() => organization?.value?.date_format);
<template>
<div class="fc-day-header-custom">
<div class="text-xs text-muted-foreground font-medium">
{{ date.format('ddd') }}
{{ date.toLocaleDateString('en-US', { weekday: 'short' }) }}
</div>
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span>
<span class="block text-xs text-muted-foreground font-medium mt-1">

View File

@@ -250,7 +250,7 @@ const calendarOptions = computed(() => ({
editable: true,
eventResizableFromStart: true,
eventDurationEditable: true,
timeZone: getUserTimezone(),
timeZone: 'America/Adak',
eventStartEditable: true,
select: handleDateSelect,
eventClick: handleEventClick,
@@ -332,16 +332,9 @@ watch(showEditTimeEntryModal, (value) => {
</template>
<template #dayHeaderContent="arg">
<FullCalendarDayHeader
:date="
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
"
:date="arg.date"
:total-minutes="
dailyTotals[
getDayJsInstance()(arg.date)
.utc()
.tz(getUserTimezone(), true)
.format('YYYY-MM-DD')
] || 0
dailyTotals[getDayJsInstance()(arg.date).format('YYYY-MM-DD')] || 0
" />
</template>
</FullCalendar>
@@ -458,7 +451,6 @@ watch(showEditTimeEntryModal, (value) => {
cursor: pointer;
box-shadow: var(--theme-shadow-card);
opacity: 0.9;
overflow: hidden;
}
.fullcalendar :deep(.fc-v-event) {

View File

@@ -33,7 +33,6 @@ const props = defineProps<{
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: (timeEntry: TimeEntry) => void;
duplicateTimeEntry: (timeEntry: TimeEntry) => void;
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
updateTimeEntry: (timeEntry: TimeEntry) => void;
deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
@@ -174,7 +173,6 @@ function onSelectChange(checked: boolean) {
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
:show-edit="false"
:show-duplicate="false"
@delete="
deleteTimeEntries(timeEntry?.timeEntries ?? [])
"></TimeEntryMoreOptionsDropdown>
@@ -204,7 +202,6 @@ function onSelectChange(checked: boolean) {
:update-time-entry="(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)"
:on-start-stop-click="() => onStartStopClick(subEntry)"
:delete-time-entry="() => deleteTimeEntries([subEntry])"
:duplicate-time-entry="() => duplicateTimeEntry(subEntry)"
:currency="currency"
:create-tag
:time-entry="subEntry"

View File

@@ -108,7 +108,6 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
tags: [...entry.tags],
});
}
function sumDuration(timeEntries: TimeEntry[]) {
return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
}
@@ -159,7 +158,6 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
:tags="tags"
:clients
:on-start-stop-click="startTimeEntryFromExisting"
:duplicate-time-entry="createTimeEntry"
:update-time-entries
:update-time-entry
:delete-time-entries
@@ -200,7 +198,6 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
: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)"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { TrashIcon, PencilIcon, DocumentDuplicateIcon } from '@heroicons/vue/20/solid';
import { TrashIcon, PencilIcon } from '@heroicons/vue/20/solid';
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,10 +10,8 @@ import {
const props = withDefaults(
defineProps<{
showEdit?: boolean;
showDuplicate?: boolean;
}>(),
{
showDuplicate: true,
showEdit: true,
}
);
@@ -21,7 +19,6 @@ const props = withDefaults(
const emit = defineEmits<{
edit: [];
delete: [];
duplicate: [];
}>();
</script>
@@ -54,14 +51,6 @@ const emit = defineEmits<{
<PencilIcon class="w-5" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="props.showDuplicate"
data-testid="time_entry_duplicate"
class="flex items-center space-x-3 cursor-pointer"
@click="emit('duplicate')">
<DocumentDuplicateIcon class="w-5" />
<span>Duplicate</span>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="time_entry_delete"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"

View File

@@ -36,7 +36,6 @@ const props = defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: () => void;
deleteTimeEntry: () => void;
duplicateTimeEntry?: () => void;
updateTimeEntry: (timeEntry: TimeEntry) => void;
currency: string;
showMember?: boolean;
@@ -167,7 +166,6 @@ async function handleDeleteTimeEntry() {
@changed="onStartStopClick"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
@edit="handleEdit"
@duplicate="duplicateTimeEntry"
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
</div>
</div>

View File

@@ -1,58 +0,0 @@
<script setup lang="ts">
import { PlusIcon, XMarkIcon } from '@heroicons/vue/20/solid';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
const props = defineProps<{
hasActiveTimer: boolean;
}>();
const emit = defineEmits<{
manualEntry: [];
discard: [];
}>();
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring hover:bg-card-background hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
aria-label="Time entry actions">
<svg
class="h-8 w-8 p-1 rounded-full"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[150px]" align="end">
<DropdownMenuItem
class="flex items-center space-x-3 cursor-pointer"
@click="emit('manualEntry')">
<PlusIcon class="w-5" />
<span>Manual time entry</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="props.hasActiveTimer"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
@click="emit('discard')">
<XMarkIcon class="w-5" />
<span>Discard</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<style scoped></style>

View File

@@ -1,9 +0,0 @@
export function openFeedback(): void {
if (
typeof window !== 'undefined' &&
'showChatWindow' in window &&
typeof window.showChatWindow === 'function'
) {
window.showChatWindow();
}
}

View File

@@ -12,19 +12,11 @@ import { useProjectsStore } from '@/utils/useProjects';
import { useMembersStore } from '@/utils/useMembers';
import { useTasksStore } from '@/utils/useTasks';
import { useClientsStore } from '@/utils/useClients';
import { useTagsStore } from '@/utils/useTags';
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
export type GroupingOption =
| 'project'
| 'task'
| 'user'
| 'billable'
| 'client'
| 'description'
| 'tag';
export type GroupingOption = 'project' | 'task' | 'user' | 'billable' | 'client' | 'description';
export const useReportingStore = defineStore('reporting', () => {
const reportingGraphResponse = ref<ReportingResponse | null>(null);
@@ -81,7 +73,6 @@ export const useReportingStore = defineStore('reporting', () => {
billable: 'Non-Billable',
client: 'No Client',
description: 'No Description',
tag: 'No Tag',
} as Record<string, string>;
function getNameForReportingRowEntry(key: string | null, type: string | null) {
@@ -115,11 +106,6 @@ export const useReportingStore = defineStore('reporting', () => {
const { clients } = storeToRefs(clientsStore);
return clients.value.find((client) => client.id === key)?.name;
}
if (type === 'tag') {
const tagsStore = useTagsStore();
const { tags } = storeToRefs(tagsStore);
return tags.value.find((tag) => tag.id === key)?.name;
}
if (type === 'billable') {
if (key === '0') {
return 'Non-Billable';
@@ -165,11 +151,6 @@ export const useReportingStore = defineStore('reporting', () => {
value: 'description',
icon: DocumentTextIcon,
},
{
label: 'Tags',
value: 'tag',
icon: DocumentTextIcon,
},
];
return {

View File

@@ -3393,241 +3393,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_store_endpoint_blocks_overlapping_entries_when_start_overlaps(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
'end' => $baseEnd->copy()->addMinutes(30)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_blocks_overlapping_entries_when_end_overlaps(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->subMinutes(30)->toIso8601ZuluString(),
'end' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_blocks_overlapping_entries_when_new_entry_is_within_existing(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->addMinutes(15)->toIso8601ZuluString(),
'end' => $baseStart->copy()->addMinutes(45)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_blocks_overlapping_entries_when_new_entry_surrounds_existing(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->subMinutes(30)->toIso8601ZuluString(),
'end' => $baseEnd->copy()->addMinutes(30)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_blocks_starting_active_entry_when_it_overlaps_with_existing(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
'end' => null,
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_allows_future_time_entries_even_with_running_now(): void
{
// Arrange
$now = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$this->travelTo($now);
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $now->copy()->subHour(),
'end' => null,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $now->copy()->addDay()->toIso8601ZuluString(),
'end' => $now->copy()->addDay()->addHour()->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(201);
}
public function test_update_endpoint_blocks_overlap_and_excludes_current_entry(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 14, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 15, 0, 0, 'UTC');
$base = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
$toUpdate = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseEnd->copy()->addMinutes(30),
'end' => $baseEnd->copy()->addHour(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $toUpdate->getKey()]), [
'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_update_multiple_refreshes_billable_rate_on_updates_time_entries(): void
{
// Arrange

View File

@@ -9,7 +9,6 @@ use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Project;
use App\Models\Tag;
use App\Models\TimeEntry;
use App\Service\TimeEntryAggregationService;
use Illuminate\Support\Carbon;
@@ -1008,201 +1007,4 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
],
], $result);
}
public function test_aggregate_time_entries_group_by_tag_includes_no_tag_and_avoids_double_counting_overall(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$start = Carbon::now();
// One entry with two tags (100s)
TimeEntry::factory()->startWithDuration($start, 100)->create([
'tags' => [$tag1->getKey(), $tag2->getKey()],
]);
// One entry with one tag (50s)
TimeEntry::factory()->startWithDuration($start, 50)->create([
'tags' => [$tag1->getKey()],
]);
// One entry with no tags (25s)
TimeEntry::factory()->startWithDuration($start, 25)->create([
'tags' => [],
]);
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Tag,
null,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert - overall total should be 175 and groups: null=25, tag1=150, tag2=100
$expected = [
'seconds' => 175,
'cost' => 0,
'grouped_type' => 'tag',
'grouped_data' => [
[
'key' => null,
'seconds' => 25,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $tag1->getKey(),
'seconds' => 150,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $tag2->getKey(),
'seconds' => 100,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
];
$this->assertEqualsCanonicalizing($expected, $result);
}
public function test_aggregate_time_entries_group_by_project_and_subgroup_tag(): void
{
// Arrange
$project = Project::factory()->create();
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$start = Carbon::now();
TimeEntry::factory()->startWithDuration($start, 120)->forProject($project)->create([
'tags' => [$tag1->getKey()],
]);
TimeEntry::factory()->startWithDuration($start, 60)->forProject($project)->create([
'tags' => [$tag2->getKey()],
]);
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
TimeEntryAggregationType::Tag,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert
$expected = [
'seconds' => 180,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 180,
'cost' => 0,
'grouped_type' => 'tag',
'grouped_data' => [
[
'key' => $tag1->getKey(),
'seconds' => 120,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $tag2->getKey(),
'seconds' => 60,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
],
];
$this->assertEqualsCanonicalizing($expected, $result);
}
public function test_aggregate_time_entries_group_by_project_and_subgroup_tag_avoids_double_counting(): void
{
// Arrange
$project = Project::factory()->create();
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$start = Carbon::now();
// One entry with two tags => subgroup rows show both tags, but project total should equal entry duration
TimeEntry::factory()->startWithDuration($start, 100)->forProject($project)->create([
'tags' => [$tag1->getKey(), $tag2->getKey()],
]);
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
TimeEntryAggregationType::Tag,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert
$expected = [
'seconds' => 100,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 100,
'cost' => 0,
'grouped_type' => 'tag',
'grouped_data' => [
[
'key' => $tag1->getKey(),
'seconds' => 100,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $tag2->getKey(),
'seconds' => 100,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
],
];
$this->assertEqualsCanonicalizing($expected, $result);
}
}