Compare commits

..

16 Commits

Author SHA1 Message Date
Gregor Vostrak
a171a9d7cd revert reka-ui update; fix DST cellMath; 2026-05-29 17:03:52 +02:00
Gregor Vostrak
805e817554 use cn helper for alert-dialog modals 2026-05-20 16:27:47 +02:00
Gregor Vostrak
cb34490e61 fix light mode timesheet background and add missing aria-label 2026-05-20 15:24:09 +02:00
Gregor Vostrak
80f0ed2bab add timesheet unit and e2e tests; add unit test CI setup 2026-05-20 15:23:22 +02:00
Gregor Vostrak
e56386d2c3 remove special “Add new project” state in TimeTrackerProjectTaskDropdown 2026-05-19 14:39:21 +02:00
Gregor Vostrak
7f75e5f1b5 set min release age for npm packages to 7 days to prevent supply chain attacks 2026-05-12 19:21:21 +02:00
Gregor Vostrak
8941aec72b remove timetrackerprojecttaskdropdown test without setup 2026-05-12 17:33:26 +02:00
Gregor Vostrak
bc1f4deaa0 fix DST boundary issue in timesheets 2026-05-12 17:25:46 +02:00
Gregor Vostrak
d292396dd4 fix "No project" duplicating rows, unify no project senitel to null 2026-05-12 17:02:26 +02:00
Gregor Vostrak
f926e8fde3 change TimeEntryFilter start filter to be inclusive 2026-05-12 16:55:01 +02:00
Gregor Vostrak
b660486eb7 fix e2e tests 2026-05-11 19:00:37 +02:00
Gregor Vostrak
61db23fc01 fix formatting 2026-05-11 17:28:29 +02:00
Gregor Vostrak
ab761d97f9 clamp running time entry duration to min 0 for FullCalendarHeaderDuration calc 2026-05-11 17:20:28 +02:00
Gregor Vostrak
50d60393bc fix formatting 2026-05-11 17:18:57 +02:00
Gregor Vostrak
b5639e4bb2 add missing dayjs plugins for isSameOrBefore and isSameOrAfter 2026-05-11 17:08:15 +02:00
Gregor Vostrak
db4af2bcac add timesheets page 2026-04-30 02:50:02 +02:00
46 changed files with 539 additions and 1015 deletions

View File

@@ -91,7 +91,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20.x'
@@ -177,7 +177,7 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v6
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -68,12 +68,12 @@ jobs:
run: cat .env
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Checkout billing extension"
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: solidtime-io/extension-billing
path: extensions/Billing
@@ -93,7 +93,7 @@ jobs:
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: solidtime-io/extension-services
path: extensions/Services
@@ -111,7 +111,7 @@ jobs:
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -92,7 +92,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20.x'
@@ -169,7 +169,7 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v6
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: "Setup PHP"
uses: shivammathur/setup-php@v2

View File

@@ -11,7 +11,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2
@@ -24,7 +24,7 @@ jobs:
run: composer install -n --prefer-dist
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20.x'

View File

@@ -9,10 +9,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20.x'

View File

@@ -11,10 +11,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20.x'

View File

@@ -11,11 +11,11 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v4
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

View File

@@ -11,9 +11,9 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v4
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

View File

@@ -10,7 +10,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2
@@ -23,7 +23,7 @@ jobs:
run: composer install -n --prefer-dist
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: "Setup PHP"
uses: shivammathur/setup-php@v2

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
@@ -48,7 +48,7 @@ jobs:
- name: "Run composer install"
run: composer install -n --prefer-dist
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: '20.x'
@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.5.1
uses: codecov/codecov-action@v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -9,9 +9,9 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: "Check code style"
uses: aglipanci/laravel-pint-action@2.6
uses: aglipanci/laravel-pint-action@2.5
with:
configPath: "pint.json"

View File

@@ -35,10 +35,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: "Setup node"
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20.x'

View File

@@ -3,18 +3,3 @@
## Reporting a Vulnerability
If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!
## Out of scope
Reports we typically won't issue an advisory for:
* Theoretical findings without a working PoC
* Raw scanner output without manual validation
* Missing/weak security headers in isolation (CSP, X-Frame-Options, HSTS, etc.)
* SPF/DKIM/DMARC on non-mail-sending domains; missing DNSSEC/CAA; TLS cipher preferences
* Self-XSS; CSRF on non-state-changing endpoints (logout, theme)
* CSV / spreadsheet formula injection in exports — treated as a spreadsheet-application issue
* Org owners or admins acting destructively within their own organization
* Anything requiring direct DB, shell, or filesystem access on a self-hosted instance
* Missing OAuth Scope enforcement (this is not implemented yet, but AI scanners flag it which is why it is included in this list until we actually support it)

View File

@@ -304,8 +304,28 @@ class JetstreamServiceProvider extends ServiceProvider
'owner' => [
'id' => $owner->getKey(),
'name' => $owner->name,
'email' => $owner->email,
'profile_photo_url' => $owner->profile_photo_url,
],
'users' => $teamModel->users->map(function (User $user): array {
return [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
'profile_photo_url' => $user->profile_photo_url,
'membership' => [
'id' => $user->membership->id,
'role' => $user->membership->role,
],
];
}),
'team_invitations' => $teamModel->teamInvitations->map(function (OrganizationInvitation $invitation): array {
return [
'id' => $invitation->getKey(),
'email' => $invitation->email,
'role' => $invitation->role,
];
}),
],
'currencies' => array_map(function (Currency $currency): string {
return $currency->getName();

View File

@@ -6,7 +6,6 @@ import { formatCentsWithOrganizationDefaults } from './utils/money';
import {
createProjectViaApi,
createPublicProjectViaApi,
createProjectMemberViaApi,
createTaskViaApi,
createClientViaApi,
createTimeEntryViaApi,
@@ -218,59 +217,6 @@ test('test that creating a non-billable project works', async ({ page }) => {
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});
test('test that creating a public project via the modal works', async ({ page }) => {
const newProjectName = 'Public Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
// Visibility defaults to Private — switch it to Public
await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
await page.getByRole('dialog').locator('#visibility').click();
await page.getByRole('option', { name: 'Public' }).click();
await Promise.all([
page.getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.is_public === true
),
]);
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});
test('test that changing a project to public via the edit modal works', async ({ page, ctx }) => {
const newProjectName = 'Edit Visibility Project ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page);
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
const projectRow = page.getByRole('row').filter({ hasText: newProjectName }).first();
await projectRow.getByRole('button').click();
await page.locator(`[aria-label='Edit Project ${newProjectName}']`).click();
// Loaded as Private — switch it to Public
await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
await page.getByRole('dialog').locator('#visibility').click();
await page.getByRole('option', { name: 'Public' }).click();
await Promise.all([
page.getByRole('button', { name: 'Update Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.is_public === true
),
]);
});
test('test that switching from custom rate to default rate clears billable rate', async ({
page,
ctx,
@@ -979,39 +925,6 @@ test.describe('Employee Projects Restrictions', () => {
employee.page.locator(`[aria-label='Delete Project ${projectName}']`)
).not.toBeVisible();
});
test('employee does not see private projects they are not a member of', async ({
ctx,
employee,
}) => {
const publicName = 'EmpPublicVisible ' + Math.floor(Math.random() * 10000);
const privateName = 'EmpPrivateHidden ' + Math.floor(Math.random() * 10000);
await createPublicProjectViaApi(ctx, { name: publicName });
// createProjectViaApi defaults to is_public: false (private); the employee is not a member
await createProjectViaApi(ctx, { name: privateName });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });
// The public project is visible — confirms the list has loaded
await expect(employee.page.getByText(publicName)).toBeVisible({ timeout: 10000 });
// The private project the employee is not a member of must not appear
await expect(employee.page.getByText(privateName)).not.toBeVisible();
});
test('employee can see a private project they are a member of', async ({ ctx, employee }) => {
const projectName = 'EmpPrivateMember ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
// Add the employee as a project member so the private project becomes visible to them
await createProjectMemberViaApi(ctx, project.id, { member_id: employee.memberId });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });
// The private project is visible because the employee is a member
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
});
});
test.describe('Employee Billable Rate Visibility', () => {

View File

@@ -19,7 +19,6 @@ import { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field';
import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibilitySelect.vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
@@ -45,7 +44,6 @@ const project = ref<CreateProjectBody>({
billable_rate: props.originalProject.billable_rate,
is_billable: props.originalProject.is_billable,
estimated_time: props.originalProject.estimated_time,
is_public: props.originalProject.is_public,
});
async function submit() {
@@ -128,7 +126,6 @@ async function submitBillableRate() {
v-if="isAllowedToPerformPremiumAction()"
v-model="project.estimated_time"
@submit="submit()"></EstimatedTimeSection>
<ProjectVisibilitySelect v-model="project.is_public"></ProjectVisibilitySelect>
</FieldGroup>
</template>
<template #footer>

View File

@@ -13,8 +13,7 @@ export type SortColumn =
| 'spent_time'
| 'progress'
| 'billable_rate'
| 'status'
| 'visibility';
| 'status';
export type SortDirection = 'asc' | 'desc';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
@@ -103,10 +102,6 @@ const columns = computed(() => [
id: 'status',
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
},
{
id: 'visibility',
accessorFn: (row: Project) => (row.is_public ? 1 : 0),
},
]);
// Columns with sortDescFirst get desc as default direction on first click.
@@ -154,7 +149,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
}
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) minmax(120px, auto) 80px;`;
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
});
</script>
@@ -176,7 +171,7 @@ const gridTemplate = computed(() => {
:sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></ProjectTableHeading>
<div v-if="sortedProjects.length === 0" class="col-span-full py-24 text-center">
<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">
{{

View File

@@ -86,14 +86,6 @@ function isChevronUp(column: SortColumn): boolean {
<ChevronUpIcon v-else-if="isChevronUp('status')" 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('visibility')">
Visibility
<ChevronDownIcon v-if="isChevronDown('visibility')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('visibility')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</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

@@ -7,8 +7,6 @@ import {
PencilSquareIcon,
ArchiveBoxIcon as ArchiveBoxIconSolid,
TrashIcon,
GlobeAltIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
@@ -143,17 +141,6 @@ const showEditProjectModal = ref(false);
<span>Active</span>
</template>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_public">
<GlobeAltIcon class="w-4 text-icon-default"></GlobeAltIcon>
<span>Public</span>
</template>
<template v-else>
<LockClosedIcon class="w-4 text-icon-default"></LockClosedIcon>
<span>Private</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">
<ProjectMoreOptionsDropdown

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { GlobeAltIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuItem } from '@/packages/ui/src';
import BaseFilterBadge from './BaseFilterBadge.vue';
type VisibilityValue = 'public' | 'private' | 'all';
const props = defineProps<{
value: VisibilityValue;
}>();
const emit = defineEmits<{
remove: [];
'update:value': [value: VisibilityValue];
}>();
const visibilityOptions = [
{ id: 'public' as const, name: 'Public' },
{ id: 'private' as const, name: 'Private' },
];
const label = computed(() => {
return visibilityOptions.find((opt) => opt.id === props.value)?.name ?? 'Visibility';
});
function updateVisibility(visibility: VisibilityValue) {
emit('update:value', visibility);
}
</script>
<template>
<BaseFilterBadge
:icon="GlobeAltIcon"
:label="label"
filter-name="Visibility"
@remove="emit('remove')">
<DropdownMenuItem
v-for="option in visibilityOptions"
:key="option.id"
:class="[value === option.id && 'bg-accent text-accent-foreground']"
@click="updateVisibility(option.id)">
{{ option.name }}
</DropdownMenuItem>
</BaseFilterBadge>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid';
import { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
import {
DropdownMenu,
@@ -19,7 +19,6 @@ import { NO_CLIENT_ID } from './constants';
export interface ProjectFilters {
status: 'active' | 'archived' | 'all';
visibility: 'public' | 'private' | 'all';
clientIds: string[];
}
@@ -37,11 +36,6 @@ const statusOptions = [
{ id: 'archived' as const, name: 'Archived' },
];
const visibilityOptions = [
{ id: 'public' as const, name: 'Public' },
{ id: 'private' as const, name: 'Private' },
];
const open = ref(false);
function updateStatus(status: 'active' | 'archived' | 'all') {
@@ -52,14 +46,6 @@ function updateStatus(status: 'active' | 'archived' | 'all') {
open.value = false;
}
function updateVisibility(visibility: 'public' | 'private' | 'all') {
emit('update:filters', {
...props.filters,
visibility,
});
open.value = false;
}
function toggleClient(clientId: string) {
const clientIds = props.filters.clientIds.includes(clientId)
? props.filters.clientIds.filter((id) => id !== clientId)
@@ -83,11 +69,7 @@ function toggleNoClient() {
}
const hasActiveFilters = computed(() => {
return (
props.filters.status !== 'all' ||
props.filters.visibility !== 'all' ||
props.filters.clientIds.length > 0
);
return props.filters.status !== 'all' || props.filters.clientIds.length > 0;
});
</script>
@@ -120,25 +102,6 @@ const hasActiveFilters = computed(() => {
</DropdownMenuSubContent>
</DropdownMenuSub>
<!-- Visibility Filter -->
<DropdownMenuSub>
<DropdownMenuSubTrigger class="gap-2">
<GlobeAltIcon class="h-4 w-4 text-icon-default" />
<span>Visibility</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
v-for="option in visibilityOptions"
:key="option.id"
:class="[
filters.visibility === option.id && 'bg-accent text-accent-foreground',
]"
@click="updateVisibility(option.id)">
{{ option.name }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<!-- Client Filter -->
<DropdownMenuSub v-if="clients.length > 0">
<DropdownMenuSubTrigger class="gap-2">

View File

@@ -62,35 +62,4 @@ describe('TimesheetCell', () => {
expect(wrapper.emitted('update')).toBeUndefined();
expect((input.element as HTMLInputElement).value).toBe(previousValue);
});
it('shows a pending 0 (delete in flight) over the cell total', () => {
const wrapper = mount(TimesheetCell, {
props: {
cell: buildCell(2 * 3600),
dayIndex: 0,
date: '2026-04-13',
isToday: false,
hasRunningEntry: false,
pendingSeconds: 0,
},
});
// `??` (not `||`): a pending 0 must win over the 2h cell total.
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('');
});
it('disables editing while the cell is saving', () => {
const wrapper = mount(TimesheetCell, {
props: {
cell: buildCell(2 * 3600),
dayIndex: 0,
date: '2026-04-13',
isToday: false,
hasRunningEntry: false,
saveStatus: 'saving',
},
});
expect((wrapper.get('input').element as HTMLInputElement).disabled).toBe(true);
});
});

View File

@@ -1,8 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { CheckIcon } from '@heroicons/vue/16/solid';
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import {
Tooltip,
TooltipContent,
@@ -10,40 +7,18 @@ import {
TooltipTrigger,
} from '@/packages/ui/src/tooltip';
import type { TimesheetCell } from '@/utils/useTimesheetGrid';
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
const props = defineProps<{
defineProps<{
cell?: TimesheetCell;
dayIndex: number;
date: string;
isToday: boolean;
hasRunningEntry: boolean;
saveStatus?: CellSaveStatus;
pendingSeconds?: number;
}>();
const emit = defineEmits<{
update: [newSeconds: number];
}>();
// Show the optimistic value while saving; `??` (not `||`) so a pending 0 (delete) wins.
const displaySeconds = computed(() => props.pendingSeconds ?? props.cell?.totalSeconds ?? 0);
const isSaving = computed(() => props.saveStatus === 'saving');
// Swap the border color (don't layer) to avoid same-specificity fights.
const inputClass = computed(() => {
const border = props.saveStatus === 'error' ? 'border-red-500/70' : 'border-input-border';
return [
'w-[80px] mx-auto text-center font-medium',
'bg-transparent text-text-primary placeholder:text-text-quaternary',
'rounded-lg border shadow-none',
border,
'hover:bg-card-background',
'focus-visible:bg-tertiary focus-visible:border-transparent',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
'disabled:cursor-wait disabled:opacity-70',
].join(' ');
});
</script>
<template>
@@ -71,26 +46,18 @@ const inputClass = computed(() => {
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
</Tooltip>
</TooltipProvider>
<template v-else>
<span class="relative inline-flex items-center">
<DurationSecondsInput
:model-value="displaySeconds"
default-unit="hours"
placeholder="-"
size="sm"
:disabled="isSaving"
:input-class="inputClass"
@commit="(seconds) => emit('update', seconds ?? 0)" />
<span
v-if="saveStatus === 'saving' || saveStatus === 'saved'"
class="pointer-events-none absolute left-full top-1/2 ml-1.5 flex -translate-y-1/2 items-center"
:aria-label="saveStatus === 'saving' ? 'Saving' : 'Saved'">
<LoadingSpinner
v-if="saveStatus === 'saving'"
class="h-3 w-3 m-0 text-text-tertiary" />
<CheckIcon v-else class="h-3 w-3 text-text-tertiary" />
</span>
</span>
</template>
<DurationSecondsInput
v-else
:model-value="cell?.totalSeconds ?? 0"
default-unit="hours"
placeholder="-"
size="sm"
input-class="w-[80px] mx-auto text-center font-medium
bg-transparent text-text-primary placeholder:text-text-quaternary
rounded-lg border border-input-border shadow-none
hover:bg-card-background
focus-visible:bg-tertiary focus-visible:border-transparent
focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
@commit="(seconds) => emit('update', seconds ?? 0)" />
</div>
</template>

View File

@@ -15,7 +15,6 @@ import type {
Task,
} from '@/packages/api/src';
import type { TimesheetRow as TimesheetRowType, TimesheetRowKey } from '@/utils/useTimesheetGrid';
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
const organization = inject<ComputedRef<Organization>>('organization');
const dayjs = getDayJsInstance();
@@ -37,8 +36,6 @@ defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
formatDuration: (seconds: number) => string;
cellStatuses: Record<string, CellSaveStatus>;
cellPendingSeconds: Record<string, number>;
}>();
const emit = defineEmits<{
@@ -63,7 +60,7 @@ const emit = defineEmits<{
class="grid min-w-full w-max border-y border-default-background-separator"
style="
grid-template-columns:
minmax(420px, 1fr) repeat(7, minmax(116px, 120px)) minmax(100px, auto)
minmax(420px, 1fr) repeat(7, minmax(96px, 120px)) minmax(100px, auto)
40px;
">
<!-- Header row -->
@@ -103,8 +100,6 @@ const emit = defineEmits<{
:create-client="createClient"
:create-tag="createTag"
:format-duration="formatDuration"
:cell-statuses="cellStatuses"
:cell-pending-seconds="cellPendingSeconds"
@remove-row="$emit('remove-row', $event)"
@cell-update="
(dayIndex, seconds) => $emit('cell-update', row, dayIndex, seconds)

View File

@@ -15,10 +15,6 @@ import type {
Organization,
} from '@/packages/api/src';
import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid';
import {
makeCellStatusKey,
type CellSaveStatus,
} from '@/utils/timesheet/useTimesheetCellMutations';
import { Button } from '@/packages/ui/src/Buttons';
const organization = inject<ComputedRef<Organization>>('organization');
@@ -38,8 +34,6 @@ const props = defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
formatDuration: (seconds: number) => string;
cellStatuses: Record<string, CellSaveStatus>;
cellPendingSeconds: Record<string, number>;
}>();
const emit = defineEmits<{
@@ -115,8 +109,6 @@ function hasRunningEntry(dayIndex: number): boolean {
:date="day"
:is-today="day === todayDate"
:has-running-entry="hasRunningEntry(dayIndex)"
:save-status="cellStatuses[makeCellStatusKey(row.key, dayIndex)]"
:pending-seconds="cellPendingSeconds[makeCellStatusKey(row.key, dayIndex)]"
@update="(seconds) => emit('cellUpdate', dayIndex, seconds)" />
<!-- Row total -->

View File

@@ -293,7 +293,7 @@ const page = usePage<{
<div class="justify-self-end">
<UpdateSidebarNotification></UpdateSidebarNotification>
<ul
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center">
<UserSettingsIcon></UserSettingsIcon>
<NavigationSidebarItem

View File

@@ -109,7 +109,7 @@ const shownTasks = computed(() => {
</div>
</li>
</ol>
<div class="px-4 space-x-1">
<div class="px-4">
<Badge v-if="project?.billable_rate">
{{ billableRateFormatted }}
/ h
@@ -118,7 +118,6 @@ const shownTasks = computed(() => {
Default Rate
</Badge>
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>
<Badge>{{ project?.is_public ? 'Public' : 'Private' }}</Badge>
</div>
</nav>
<div>

View File

@@ -20,7 +20,6 @@ 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 ProjectVisibilityFilterBadge from '@/Components/Common/Project/ProjectVisibilityFilterBadge.vue';
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
@@ -37,7 +36,6 @@ interface ProjectTableState {
filters: {
clientIds: string[];
status: 'active' | 'archived' | 'all';
visibility: 'public' | 'private' | 'all';
};
}
@@ -49,17 +47,10 @@ const tableState = useStorage<ProjectTableState>(
filters: {
clientIds: [],
status: 'all',
visibility: 'all',
},
},
undefined,
{
mergeDefaults: (storage, defaults) => ({
...defaults,
...storage,
filters: { ...defaults.filters, ...storage.filters },
}),
}
{ mergeDefaults: true }
);
function handleSort(column: SortColumn, direction: SortDirection) {
@@ -78,14 +69,6 @@ const filteredProjects = computed(() => {
return false;
}
// Visibility filter
if (tableState.value.filters.visibility === 'public' && !project.is_public) {
return false;
}
if (tableState.value.filters.visibility === 'private' && project.is_public) {
return false;
}
// Client filter
const hasClientFilter = tableState.value.filters.clientIds.length > 0;
if (hasClientFilter) {
@@ -108,10 +91,6 @@ function removeStatusFilter() {
tableState.value.filters.status = 'all';
}
function removeVisibilityFilter() {
tableState.value.filters.visibility = 'all';
}
function removeClientFilter() {
tableState.value.filters.clientIds = [];
}
@@ -173,15 +152,6 @@ const showBillableRate = computed(() => {
tableState.filters.status = $event as 'active' | 'archived' | 'all'
" />
<ProjectVisibilityFilterBadge
v-if="tableState.filters.visibility !== 'all'"
data-testid="visibility-filter-badge"
:value="tableState.filters.visibility"
@remove="removeVisibilityFilter"
@update:value="
tableState.filters.visibility = $event as 'public' | 'private' | 'all'
" />
<ProjectClientFilterBadge
v-if="tableState.filters.clientIds.length > 0"
data-testid="client-filter-badge"

View File

@@ -0,0 +1,448 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { router, useForm, usePage } from '@inertiajs/vue3';
import ActionMessage from '@/Components/ActionMessage.vue';
import ActionSection from '@/Components/ActionSection.vue';
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import FormSection from '@/Components/FormSection.vue';
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import SectionBorder from '@/Components/SectionBorder.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import type { Organization, OrganizationInvitation, User } from '@/types/models';
import type { Membership, Permissions, Role } from '@/types/jetstream';
import { filterRoles } from '@/utils/roles';
type UserWithMembership = User & { membership: Membership };
const props = defineProps<{
team: Organization;
availableRoles: Role[];
userPermissions: Permissions;
}>();
const users = computed(() => {
return props.team.users as Array<UserWithMembership>;
});
const page = usePage<{
auth: {
user: User;
};
}>();
const addTeamMemberForm = useForm({
email: '',
role: null as string | null,
});
const updateRoleForm = useForm({
role: null as string | null,
});
const leaveTeamForm = useForm({});
const removeTeamMemberForm = useForm({});
const currentlyManagingRole = ref(false);
const managingRoleFor = ref<User | null>(null);
const confirmingLeavingTeam = ref(false);
const teamMemberBeingRemoved = ref<User | null>(null);
const addTeamMember = () => {
addTeamMemberForm.post(route('team-members.store', props.team.id), {
errorBag: 'addTeamMember',
preserveScroll: true,
onSuccess: () => addTeamMemberForm.reset(),
});
};
const cancelTeamInvitation = (invitation: OrganizationInvitation) => {
router.delete(route('team-invitations.destroy', invitation.id), {
preserveScroll: true,
});
};
const manageRole = (teamMember: User & { membership: Membership }) => {
managingRoleFor.value = teamMember;
updateRoleForm.role = teamMember.membership.role;
currentlyManagingRole.value = true;
};
const updateRole = () => {
updateRoleForm.put(
route('team-members.update', {
team: props.team.id,
user: managingRoleFor.value?.id,
}),
{
preserveScroll: true,
onSuccess: () => (currentlyManagingRole.value = false),
}
);
};
const confirmLeavingTeam = () => {
confirmingLeavingTeam.value = true;
};
const leaveTeam = () => {
leaveTeamForm.delete(route('team-members.destroy', [props.team.id, page.props.auth.user.id]));
};
const confirmTeamMemberRemoval = (teamMember: User) => {
teamMemberBeingRemoved.value = teamMember;
};
const removeTeamMember = () => {
removeTeamMemberForm.delete(
route('team-members.destroy', {
team: props.team.id,
user: teamMemberBeingRemoved.value?.id,
}),
{
errorBag: 'removeTeamMember',
preserveScroll: true,
preserveState: true,
onSuccess: () => (teamMemberBeingRemoved.value = null),
}
);
};
const displayableRole = (role: string) => {
return props.availableRoles.find((r) => r.key === role)?.name;
};
</script>
<template>
<div>
<div v-if="userPermissions.canAddTeamMembers">
<SectionBorder />
<!-- Add Organization Member -->
<FormSection @submitted="addTeamMember">
<template #title> Add Organization Member</template>
<template #description>
Add a new member to your organization, allowing them to collaborate with you.
</template>
<template #form>
<div class="col-span-6">
<div class="max-w-xl text-sm text-muted">
Please provide the email address of the person you would like to add to
this organization.
</div>
</div>
<!-- Member Email -->
<Field class="col-span-6 sm:col-span-4">
<FieldLabel for="email">Email</FieldLabel>
<TextInput
id="email"
v-model="addTeamMemberForm.email"
type="email"
class="block w-full" />
<FieldError v-if="addTeamMemberForm.errors.email">{{
addTeamMemberForm.errors.email
}}</FieldError>
</Field>
<!-- Role -->
<div v-if="availableRoles.length > 0" class="col-span-6 lg:col-span-4">
<FieldLabel for="roles">Role</FieldLabel>
<FieldError v-if="addTeamMemberForm.errors.role">{{
addTeamMemberForm.errors.role
}}</FieldError>
<div
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
<button
v-for="(role, i) in filterRoles(availableRoles)"
:key="role.key"
type="button"
class="relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
:class="{
'border-t border-card-border focus:border-none rounded-t-none':
i > 0,
'rounded-b-none': i != Object.keys(availableRoles).length - 1,
}"
@click="addTeamMemberForm.role = role.key">
<div
:class="{
'opacity-50':
addTeamMemberForm.role &&
addTeamMemberForm.role != role.key,
}">
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-text-primary"
:class="{
'font-semibold': addTeamMemberForm.role == role.key,
}">
{{ role.name }}
</div>
<svg
v-if="addTeamMemberForm.role == role.key"
class="ms-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>
<!-- Role Description -->
<div class="mt-2 text-xs text-muted text-start">
{{ role.description }}
</div>
</div>
</button>
</div>
</div>
</template>
<template #actions>
<ActionMessage :on="addTeamMemberForm.recentlySuccessful" class="me-3">
Added.
</ActionMessage>
<PrimaryButton
:class="{ 'opacity-25': addTeamMemberForm.processing }"
:disabled="addTeamMemberForm.processing">
Add
</PrimaryButton>
</template>
</FormSection>
</div>
<div v-if="team.team_invitations.length > 0 && userPermissions.canAddTeamMembers">
<SectionBorder />
<!-- Organization Member Invitations -->
<ActionSection class="mt-10 sm:mt-0">
<template #title> Pending Organization Invitations</template>
<template #description>
These people have been invited to your organization and have been sent an
invitation email. They may join the organization by accepting the email
invitation.
</template>
<!-- Pending Organization Member Invitation List -->
<template #content>
<div class="space-y-6">
<div
v-for="invitation in team.team_invitations"
:key="invitation.id"
class="flex items-center justify-between">
<div class="text-muted">
{{ invitation.email }}
</div>
<div class="flex items-center">
<!-- Cancel Organization Invitation -->
<button
v-if="userPermissions.canRemoveTeamMembers"
class="cursor-pointer ms-6 text-sm text-red-500 focus:outline-none"
@click="cancelTeamInvitation(invitation)">
Cancel
</button>
</div>
</div>
</div>
</template>
</ActionSection>
</div>
<div v-if="users.length > 0">
<SectionBorder />
<!-- Manage Organization Members -->
<ActionSection class="mt-10 sm:mt-0">
<template #title> Organization Members</template>
<template #description>
All of the people that are part of this organization.
</template>
<!-- Organization Member List -->
<template #content>
<div class="space-y-6">
<div
v-for="user in users"
:key="user.id"
class="flex items-center justify-between">
<div class="flex items-center">
<img
class="w-8 h-8 rounded-full object-cover"
:src="user.profile_photo_url"
:alt="user.name" />
<div class="ms-4 text-text-primary">
{{ user.name }}
</div>
</div>
<div class="flex items-center">
<!-- Manage Organization Member Role -->
<button
v-if="
userPermissions.canUpdateTeamMembers &&
availableRoles.length
"
class="ms-2 text-sm text-gray-400 underline"
@click="manageRole(user)">
{{ displayableRole(user.membership.role) }}
</button>
<div
v-else-if="availableRoles.length"
class="ms-2 text-sm text-gray-400">
{{ displayableRole(user.membership.role) }}
</div>
<!-- Leave Organization -->
<button
v-if="page.props.auth.user.id === user.id"
class="cursor-pointer ms-6 text-sm text-red-500"
@click="confirmLeavingTeam">
Leave
</button>
<!-- Remove Organization Member -->
<button
v-else-if="userPermissions.canRemoveTeamMembers"
class="cursor-pointer ms-6 text-sm text-red-500"
@click="confirmTeamMemberRemoval(user)">
Remove
</button>
</div>
</div>
</div>
</template>
</ActionSection>
</div>
<!-- Role Management Modal -->
<DialogModal :show="currentlyManagingRole" @close="currentlyManagingRole = false">
<template #title> Manage Role</template>
<template #content>
<div v-if="managingRoleFor">
<div
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
<button
v-for="(role, i) in availableRoles"
:key="role.key"
type="button"
class="relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
:class="{
'border-t border-card-border focus:border-none rounded-t-none':
i > 0,
'rounded-b-none': i !== Object.keys(availableRoles).length - 1,
}"
@click="updateRoleForm.role = role.key">
<div
:class="{
'opacity-50':
updateRoleForm.role && updateRoleForm.role !== role.key,
}">
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-muted"
:class="{
'font-semibold': updateRoleForm.role === role.key,
}">
{{ role.name }}
</div>
<svg
v-if="updateRoleForm.role == role.key"
class="ms-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>
<!-- Role Description -->
<div class="mt-2 text-xs text-muted">
{{ role.description }}
</div>
</div>
</button>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="currentlyManagingRole = false"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': updateRoleForm.processing }"
:disabled="updateRoleForm.processing"
@click="updateRole">
Save
</PrimaryButton>
</template>
</DialogModal>
<!-- Leave Organization Confirmation Modal -->
<ConfirmationModal :show="confirmingLeavingTeam" @close="confirmingLeavingTeam = false">
<template #title> Leave Organization</template>
<template #content> Are you sure you would like to leave this organization? </template>
<template #footer>
<SecondaryButton @click="confirmingLeavingTeam = false"> Cancel </SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': leaveTeamForm.processing }"
:disabled="leaveTeamForm.processing"
@click="leaveTeam">
Leave
</DangerButton>
</template>
</ConfirmationModal>
<!-- Remove Organization Member Confirmation Modal -->
<ConfirmationModal :show="!!teamMemberBeingRemoved" @close="teamMemberBeingRemoved = null">
<template #title> Remove Organization Member</template>
<template #content>
Are you sure you would like to remove this person from the organization?
</template>
<template #footer>
<SecondaryButton @click="teamMemberBeingRemoved = null"> Cancel </SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': removeTeamMemberForm.processing }"
:disabled="removeTeamMemberForm.processing"
@click="removeTeamMember">
Remove
</DangerButton>
</template>
</ConfirmationModal>
</div>
</template>

View File

@@ -51,6 +51,9 @@ const updateTeamName = () => {
<div class="text-text-primary">
{{ team.owner.name }}
</div>
<div class="text-text-secondary text-sm">
{{ team.owner.email }}
</div>
</div>
</div>
</div>

View File

@@ -90,12 +90,7 @@ const weekRangeDisplay = computed(() => {
});
// ── Cell / row mutation handlers ──────────────────────────────────
const { handleCellUpdate, cellStatus, cellPendingSeconds } = useTimesheetCellMutations(
weekDays,
timeEntries,
rows,
removeSlot
);
const { handleCellUpdate } = useTimesheetCellMutations(weekDays, timeEntries, rows, removeSlot);
const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations(
mutations,
@@ -172,8 +167,6 @@ async function createTag(name: string): Promise<Tag | undefined> {
:create-client="createClient"
:create-tag="createTag"
:format-duration="formatDuration"
:cell-statuses="cellStatus"
:cell-pending-seconds="cellPendingSeconds"
@remove-row="handleRemoveRow"
@cell-update="handleCellUpdate"
@project-task-change="

View File

@@ -114,8 +114,6 @@ export type ApiToken = ApiTokenIndexResponse['data'][0];
export type DetailedInvoiceResponse = ZodiosResponseByAlias<SolidTimeApi, 'getInvoice'>;
export type DetailedInvoice = DetailedInvoiceResponse['data'];
export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0];
export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>;

View File

@@ -1886,54 +1886,6 @@ const endpoints = makeApi([
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/invoices/:invoice/copy',
alias: 'copyInvoice',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ reference: z.string() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoice',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: DetailedInvoiceResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/invoices/:invoice',

View File

@@ -35,8 +35,7 @@ watch(open, (isOpen) => {
sortedItems.value = [...props.items].sort((a, b) => {
const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;
const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;
if (aSelected !== bSelected) return aSelected - bSelected;
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
return aSelected - bSelected;
});
}
});

View File

@@ -15,7 +15,6 @@ import { UserCircleIcon } from '@heroicons/vue/20/solid';
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
import { Field, FieldGroup, FieldLabel } from '../field';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibilitySelect.vue';
import type { Client } from '@/packages/api/src';
const show = defineModel('show', { default: false });
@@ -42,7 +41,6 @@ const project = ref<CreateProjectBody>({
billable_rate: null,
is_billable: false,
estimated_time: null,
is_public: false,
});
async function submit() {
@@ -55,7 +53,6 @@ async function submit() {
billable_rate: null,
is_billable: false,
estimated_time: null,
is_public: false,
};
}
@@ -126,7 +123,6 @@ const currentClientName = computed(() => {
v-if="enableEstimatedTime"
v-model="project.estimated_time"
@submit="submit()"></EstimatedTimeSection>
<ProjectVisibilitySelect v-model="project.is_public"></ProjectVisibilitySelect>
</FieldGroup>
</template>
<template #footer>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Field, FieldDescription, FieldLabel } from '../field';
import { GlobeAltIcon } from '@heroicons/vue/20/solid';
const isPublic = defineModel<boolean>({ default: false });
const visibility = computed({
get: () => (isPublic.value ? 'public' : 'private'),
set: (value: string) => {
isPublic.value = value === 'public';
},
});
const description = computed(() =>
isPublic.value
? 'This project is visible to all members of the organization.'
: 'This project is only visible to its project members.'
);
</script>
<template>
<Field>
<FieldLabel :icon="GlobeAltIcon" for="visibility">Visibility</FieldLabel>
<Select v-model="visibility">
<SelectTrigger id="visibility">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">Private</SelectItem>
<SelectItem value="public">Public</SelectItem>
</SelectContent>
</Select>
<FieldDescription>{{ description }}</FieldDescription>
</Field>
</template>
<style scoped></style>

View File

@@ -31,7 +31,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<div
:class="
cn(
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-14 xl:pt-24 justify-center overflow-auto'
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-20 xl:pt-32 justify-center overflow-auto'
)
">
<DialogContent

View File

@@ -12,7 +12,7 @@ const props = defineProps<{
data-slot="field-group"
:class="
cn(
'group/field-group @container/field-group flex w-full flex-col gap-6 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
props.class
)
">

View File

@@ -22,7 +22,9 @@ export interface Organization {
currency: string;
created_at: string | null;
updated_at: string | null;
owner: Pick<User, 'id' | 'name' | 'profile_photo_url'>;
owner: User;
users: User[];
team_invitations: OrganizationInvitation[];
}
export interface OrganizationInvitation {
id: string;

View File

@@ -29,7 +29,9 @@ export interface Organization {
created_at: string | null;
updated_at: string | null;
// relations
owner: Pick<User, 'id' | 'name' | 'profile_photo_url'>;
owner: User;
users: User[];
team_invitations: OrganizationInvitation[];
}
export interface OrganizationInvitation {

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { useTimesheetCellMutations, makeCellStatusKey } from './useTimesheetCellMutations';
import { useTimesheetCellMutations } from './useTimesheetCellMutations';
import { api } from '@/packages/api/src';
import type { TimesheetRow, TimesheetCell } from '@/utils/useTimesheetGrid';
import type { TimeEntry } from '@/packages/api/src';
@@ -549,119 +549,3 @@ describe('useTimesheetCellMutations.handleCellUpdate', () => {
});
});
});
describe('useTimesheetCellMutations save status', () => {
// Timer handles keep old fade-outs from clearing newer status, and
// the same-cell saving guard prevents concurrent writes from stale rows.
it('does not let a stale fade-out timer clear a newer edit on the same cell', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('saved');
// Re-edit the same cell partway through the first "saved" window.
vi.advanceTimersByTime(1000);
await cellMutations.handleCellUpdate(row, 0, 2 * HOUR);
expect(cellMutations.cellPendingSeconds.value[key]).toBe(2 * HOUR);
// Advance past the FIRST timer's deadline: it must not wipe the newer state.
vi.advanceTimersByTime(2000);
expect(cellMutations.cellStatus.value[key]).toBe('saved');
expect(cellMutations.cellPendingSeconds.value[key]).toBe(2 * HOUR);
});
it('ignores another commit while the same cell is saving', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
let release!: () => void;
const gateA = new Promise<void>((res) => {
release = () => res();
});
apiMocks.createTimeEntry.mockImplementationOnce(async () => {
await gateA;
return { data: { id: 'a' } } as never;
});
const save = cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('saving');
expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR);
// The second commit would be planned from the same stale row, so it is ignored.
await cellMutations.handleCellUpdate(row, 0, 2 * HOUR);
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR);
release();
await save;
expect(cellMutations.cellStatus.value[key]).toBe('saved');
expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR);
});
it('marks error and drops the optimistic value when the save fails', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('boom'));
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('error');
expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined();
expect(addNotification).toHaveBeenCalledWith(
'error',
'Failed to update timesheet',
expect.any(String)
);
});
it('marks error and drops the optimistic value when the day is full', async () => {
// Block all but the last 2h, then ask for 3h → NoFreeWindowError.
const blocker = entry('2026-04-10T00:00:00Z', '2026-04-10T22:00:00Z', { id: 'blocker' });
const { cellMutations } = setup([blocker]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
await cellMutations.handleCellUpdate(row, 0, 3 * HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('error');
expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined();
expect(addNotification).toHaveBeenCalledWith(
'error',
"This day can't fit any more work",
expect.any(String)
);
});
it('creates no status when the committed value is unchanged', async () => {
const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z');
const { cellMutations } = setup([cellEntry]);
const row = buildRow('p-1', [cellEntry]);
const key = makeCellStatusKey(row.key, 0);
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBeUndefined();
expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined();
});
it('tracks save status independently for each cell', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const mondayKey = makeCellStatusKey(row.key, 0);
const tuesdayKey = makeCellStatusKey(row.key, 1);
await cellMutations.handleCellUpdate(row, 0, HOUR);
await cellMutations.handleCellUpdate(row, 1, 2 * HOUR);
expect(cellMutations.cellStatus.value[mondayKey]).toBe('saved');
expect(cellMutations.cellStatus.value[tuesdayKey]).toBe('saved');
expect(cellMutations.cellPendingSeconds.value[mondayKey]).toBe(HOUR);
expect(cellMutations.cellPendingSeconds.value[tuesdayKey]).toBe(2 * HOUR);
});
});

View File

@@ -1,4 +1,4 @@
import { ref, type Ref } from 'vue';
import type { Ref } from 'vue';
import { useQueryClient } from '@tanstack/vue-query';
import { api, type CreateTimeEntryBody, type TimeEntry } from '@/packages/api/src';
import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
@@ -19,17 +19,6 @@ import {
type FreeWindow,
} from './cellMath';
export type CellSaveStatus = 'saving' | 'saved' | 'error';
/** Map key for a cell's save state (row + day). */
export function makeCellStatusKey(rowKey: TimesheetRowKey, dayIndex: number): string {
return `${rowKey}:${dayIndex}`;
}
/** How long the saved/error state stays visible before fading. */
const SAVED_VISIBLE_MS = 2800;
const ERROR_VISIBLE_MS = 2500;
/**
* Cell-level edit dispatcher. Picks one of four strategies based on
* the diff between current and requested totals:
@@ -59,58 +48,15 @@ export function useTimesheetCellMutations(
const queryClient = useQueryClient();
const notifications = useNotificationsStore();
// Save status + the optimistic value shown while saving, so a saved cell
// doesn't flicker back to its old total before the refetch lands.
const cellStatus = ref<Record<string, CellSaveStatus>>({});
const cellPendingSeconds = ref<Record<string, number>>({});
const statusClearTimers: Record<string, ReturnType<typeof setTimeout>> = {};
function clearStatusTimer(key: string): void {
clearTimeout(statusClearTimers[key]);
delete statusClearTimers[key];
}
function beginSaving(key: string, seconds: number): void {
clearStatusTimer(key);
cellPendingSeconds.value[key] = seconds;
cellStatus.value[key] = 'saving';
}
function markSaved(key: string): void {
clearStatusTimer(key);
cellStatus.value[key] = 'saved';
statusClearTimers[key] = setTimeout(() => {
delete cellStatus.value[key];
delete cellPendingSeconds.value[key];
delete statusClearTimers[key];
}, SAVED_VISIBLE_MS);
}
function markError(key: string): void {
clearStatusTimer(key);
cellStatus.value[key] = 'error';
// Drop the optimistic value so the cell shows server truth after refetch.
delete cellPendingSeconds.value[key];
statusClearTimers[key] = setTimeout(() => {
delete cellStatus.value[key];
delete statusClearTimers[key];
}, ERROR_VISIBLE_MS);
}
async function handleCellUpdate(
row: TimesheetRow,
dayIndex: number,
newTotalSeconds: number
): Promise<void> {
const statusKey = makeCellStatusKey(row.key, dayIndex);
if (cellStatus.value[statusKey] === 'saving') return;
const cell = row.cells.get(dayIndex);
const existingSeconds = cell?.totalSeconds ?? 0;
if (newTotalSeconds === existingSeconds) return;
beginSaving(statusKey, newTotalSeconds);
// Capture row state before the mutation: a row that was empty
// and shares identity with another slot collapses after the
// first entry lands, so the entry naturally identity-routes to
@@ -128,9 +74,7 @@ export function useTimesheetCellMutations(
'Another row with the same project, task, billable status and tags already exists.'
);
}
markSaved(statusKey);
} catch (err) {
markError(statusKey);
if (err instanceof NoFreeWindowError) {
const friendlyDuration = formatHumanReadableDuration(
err.requiredSeconds,
@@ -149,6 +93,7 @@ export function useTimesheetCellMutations(
'Failed to update timesheet',
'Please try again later.'
);
throw err;
} finally {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
}
@@ -371,5 +316,5 @@ export function useTimesheetCellMutations(
return best;
}
return { handleCellUpdate, cellStatus, cellPendingSeconds };
return { handleCellUpdate };
}

View File

@@ -308,22 +308,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_show_endpoint_fails_if_employee_tries_to_access_public_project(): void
{
// Arrange
// Employees do not have the projects:view:all permission that the show endpoint requires,
// so they are forbidden even from public projects (they list them via the index endpoint instead).
$data = $this->createUserWithRole(Role::Employee);
$publicProject = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $publicProject->getKey()]));
// Assert
$response->assertForbidden();
}
public function test_store_endpoint_fails_if_user_has_no_permission_to_create_projects(): void
{
// Arrange
@@ -343,29 +327,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_store_endpoint_fails_if_user_is_employee(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseMissing(Project::class, [
'name' => $projectFake->name,
'organization_id' => $data->organization->getKey(),
]);
}
public function test_store_endpoint_highest_possible_billable_rate_can_be_stored_in_database(): void
{
// Arrange
@@ -707,124 +668,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_store_endpoint_creates_public_project(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'is_public' => true,
]);
// Assert
$response->assertStatus(201);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', true)
->etc()
);
$this->assertDatabaseHas(Project::class, [
'name' => $projectFake->name,
'organization_id' => $projectFake->organization_id,
'is_public' => true,
]);
}
public function test_store_endpoint_creates_private_project_if_is_public_is_false(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'is_public' => false,
]);
// Assert
$response->assertStatus(201);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', false)
->etc()
);
$this->assertDatabaseHas(Project::class, [
'name' => $projectFake->name,
'organization_id' => $projectFake->organization_id,
'is_public' => false,
]);
}
public function test_store_endpoint_creates_private_project_by_default_if_is_public_is_not_given(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertStatus(201);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', false)
->etc()
);
$this->assertDatabaseHas(Project::class, [
'name' => $projectFake->name,
'organization_id' => $projectFake->organization_id,
'is_public' => false,
]);
}
public function test_store_endpoint_fails_if_is_public_is_not_boolean(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'is_public' => 'public',
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['is_public']);
}
public function test_update_endpoint_fails_if_user_is_not_part_of_project_organization(): void
{
// Arrange
@@ -870,30 +713,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_update_endpoint_fails_if_user_is_employee(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Employee Updated Name',
'color' => $project->color,
'client_id' => null,
'is_billable' => $project->is_billable,
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseMissing(Project::class, [
'id' => $project->getKey(),
'name' => 'Employee Updated Name',
]);
}
public function test_update_endpoint_can_update_project_if_project_name_already_exists_in_organization_but_with_different_client(): void
{
// Arrange
@@ -1138,120 +957,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$this->assertFalse($project->is_archived);
}
public function test_update_endpoint_can_make_a_private_project_public(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'is_billable' => $project->is_billable,
'client_id' => null,
'is_public' => true,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', true)
->etc()
);
$project->refresh();
$this->assertTrue($project->is_public);
}
public function test_update_endpoint_can_make_a_public_project_private(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'is_billable' => $project->is_billable,
'client_id' => null,
'is_public' => false,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', false)
->etc()
);
$project->refresh();
$this->assertFalse($project->is_public);
}
public function test_update_endpoint_keeps_project_visibility_if_is_public_is_not_given(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'is_billable' => $project->is_billable,
'client_id' => null,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', true)
->etc()
);
$project->refresh();
$this->assertTrue($project->is_public);
}
public function test_update_endpoint_fails_if_is_public_is_not_boolean(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'is_billable' => $project->is_billable,
'client_id' => null,
'is_public' => 'public',
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['is_public']);
$project->refresh();
$this->assertTrue($project->is_public);
}
public function test_update_endpoint_ignores_estimated_time_if_pro_features_are_disabled(): void
{
// Arrange
@@ -1470,23 +1175,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_destroy_endpoint_fails_if_user_is_employee(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Project::class, [
'id' => $project->getKey(),
]);
}
public function test_destroy_endpoint_fails_if_project_is_still_in_use_by_a_task(): void
{
// Arrange

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Web;
use App\Models\OrganizationInvitation;
use App\Providers\JetstreamServiceProvider;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Jetstream\Jetstream;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(JetstreamServiceProvider::class)]
class TeamShowEndpointTest extends EndpointTestAbstract
{
protected function setUp(): void
{
Jetstream::$inertiaManager = null;
parent::setUp();
}
public function test_team_show_does_not_expose_member_roster_invitations_or_owner_email(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'email' => 'pending@example.com',
]);
$this->actingAs($data->user);
// Act
$response = $this->get('/teams/'.$data->organization->getKey());
// Assert
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->missing('team.users')
->missing('team.team_invitations')
->missing('team.owner.email')
->has('team.owner.id')
->has('team.owner.name')
->has('team.owner.profile_photo_url')
);
}
}