Compare commits

..

40 Commits

Author SHA1 Message Date
Gregor Vostrak
797fddf638 chore: Add zod/type deps and tighten TimeTracker types 2026-03-24 17:41:26 +01:00
Gregor Vostrak
d07294ae7c add zodios to external ui package dependencies 2026-03-23 19:55:26 +01:00
Gregor Vostrak
1f49940805 Use Bundler moduleResolution and add PostCSS config for ui package 2026-03-23 19:38:07 +01:00
Gregor Vostrak
6be6a48e0d Use relative cn imports in UI package to improve isolation and fix
package build
2026-03-23 19:16:31 +01:00
Gregor Vostrak
b94a04dca0 Move useCssVariable into ui package 2026-03-23 19:02:20 +01:00
Gregor Vostrak
bd3b8f265f chore: cleanup old tabs reexports and ui version bump 2026-03-23 17:57:28 +01:00
Gregor Vostrak
c19a0f9acc Move tabs and TabBar into UI package 2026-03-23 17:43:46 +01:00
Gregor Vostrak
5c6d84dc38 fix e2e tests timing issues with cut off time entries at the start of
the day
2026-03-23 17:43:46 +01:00
Gregor Vostrak
5c67709746 Add clearable DatePicker and report tests 2026-03-23 17:43:46 +01:00
Gregor Vostrak
a2b0828c54 Fix flaky e2e tests for calendar and projects 2026-03-23 17:43:46 +01:00
Gregor Vostrak
b94872b07b Add size prop to DatePicker and fix range end 2026-03-23 17:43:46 +01:00
Gregor Vostrak
12bbbf64e9 Add context menu actions and tests 2026-03-23 17:43:46 +01:00
Gregor Vostrak
c07ac4b0e4 add random identifier to exports to avoid path conflicts, fixes #1035 2026-03-23 17:43:46 +01:00
Gregor Vostrak
a58566d002 fix design inconsistencies in time entry edit modal 2026-03-23 17:43:46 +01:00
Gregor Vostrak
57ed6036e6 Add context menu to time entry rows 2026-03-23 17:43:46 +01:00
Gregor Vostrak
ef7569b63b only show calendar toolbar after load complete to avoid layout shift 2026-03-23 17:43:46 +01:00
Gregor Vostrak
19c789b78e fix flaky firefox e2e test 2026-03-23 17:43:46 +01:00
Gregor Vostrak
49548037b3 fix calendar and calendar settings e2e test regressions after migration 2026-03-23 17:43:46 +01:00
Gregor Vostrak
97df779d1e Use locale-aware parseTimeInput for duration inputs 2026-03-23 17:43:46 +01:00
Gregor Vostrak
a1d5563fc4 fix window type error for activity test data injection 2026-03-23 17:43:46 +01:00
Gregor Vostrak
c94ca804f8 add Progress component and Reorganize UI exports 2026-03-23 17:43:46 +01:00
Gregor Vostrak
189682cfaf Replace FullCalendar with custom calendar UI 2026-03-23 17:43:46 +01:00
Gregor Vostrak
8d16503541 Adjust UI sizing and spacing 2026-03-23 17:43:46 +01:00
Gregor Vostrak
e43ce477b8 externalize npm packages in ui package 2026-03-23 17:43:46 +01:00
Gregor Vostrak
5646aedb25 add lucide-vue-next to peer dependencies 2026-03-23 17:43:46 +01:00
Gregor Vostrak
2b46e568e0 Use nearest-grid snapping for event resize 2026-03-23 17:43:46 +01:00
Gregor Vostrak
89a4a1962a Replace fullcalendar calendar header with custom toolbar 2026-03-23 17:43:46 +01:00
Gregor Vostrak
c581ad8854 move calendar, dropdown-menu, select, dialog, number-field components to
the ui package
2026-03-23 17:43:46 +01:00
Gregor Vostrak
bce6cb9395 Move dropdown menu into UI package 2026-03-23 17:43:46 +01:00
Gregor Vostrak
1cdae98ed9 Add context menu actions for running entries in calendar 2026-03-23 17:43:46 +01:00
Gregor Vostrak
02f6436fd0 keep calendar event data while resizing event 2026-03-23 17:43:46 +01:00
Gregor Vostrak
452acca942 add context menus to calendar view + ui package 2026-03-23 17:43:46 +01:00
Gregor Vostrak
192c8c3b88 fix IDOR private projects 2026-03-19 13:52:28 +01:00
Gregor Vostrak
6218ffceb5 update composer dependencies 2026-03-03 12:27:42 +01:00
Gregor Vostrak
ba32be0543 update npm dependencies 2026-03-02 18:19:11 +01:00
Gregor Vostrak
bd817db06f only use xsrf token for organization requests 2026-03-02 17:18:21 +01:00
Gregor Vostrak
97f4bce676 bump retries and wait for networkidle in retry 2026-03-02 17:18:21 +01:00
Gregor Vostrak
6962b668fb add retries to api data token setup and xsrf token fallback 2026-03-02 17:18:21 +01:00
Gregor Vostrak
be8091296c use api tokens to create e2e test data 2026-03-02 17:18:21 +01:00
Gregor Vostrak
84c4750c9b Add warning for AI slop pull requests
Added a warning about AI slop pull requests and potential bans.
2026-02-27 20:18:44 +01:00
222 changed files with 9389 additions and 2773 deletions

View File

@@ -37,6 +37,8 @@ If you have a **feature request**, please [**create a discussion**](https://gith
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

View File

@@ -78,7 +78,7 @@ class ProjectController extends Controller
*/
public function show(Organization $organization, Project $project): JsonResource
{
$this->checkPermission($organization, 'projects:view', $project);
$this->checkPermission($organization, 'projects:view:all', $project);
// Note: There is currently no need to check if a user is a member of the project,
// since this is only relevant for users with the role "employee" and they can not access this endpoint.

View File

@@ -53,6 +53,7 @@ use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Facades\Excel;
use Spatie\TemporaryDirectory\TemporaryDirectory;
@@ -246,7 +247,7 @@ class TimeEntryController extends Controller
'user',
'tagsRelation',
]);
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension();
$folderPath = 'exports';
$path = $folderPath.'/'.$filename;
$localizationService = LocalizationService::forOrganization($organization);
@@ -469,7 +470,7 @@ class TimeEntryController extends Controller
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
$localizationService = LocalizationService::forOrganization($organization);
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension();
$folderPath = 'exports';
$path = $folderPath.'/'.$filename;

540
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { createBareTimeEntryViaApi, createTimeEntryWithTimestampsViaApi } from './utils/api';
async function goToCalendar(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
await expect(page.locator('.fc')).toBeVisible();
await expect(page.locator('.fc')).toBeVisible({ timeout: 10000 });
}
async function openSettingsPopover(page: Page) {
@@ -17,6 +18,29 @@ async function clearCalendarSettings(page: Page) {
await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings'));
}
function getCalendarTitle(page: Page) {
return page.getByTestId('calendar-title');
}
async function scrollCalendarToTime(page: Page, time: string) {
await page.evaluate((t) => {
const slot = document.querySelector(`.fc-timegrid-slot-lane[data-time="${t}"]`);
if (slot) slot.scrollIntoView({ block: 'start' });
}, time);
await page.waitForTimeout(300);
}
async function getSlotHeight(page: Page): Promise<number> {
return await page.evaluate(() => {
const slots = Array.from(document.querySelectorAll('.fc-timegrid-slot-lane'));
for (let i = 0; i < slots.length; i++) {
const h = slots[i].getBoundingClientRect().height;
if (h > 0) return h;
}
return 20;
});
}
test.describe('Calendar Settings', () => {
test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page);
@@ -39,7 +63,9 @@ test.describe('Calendar Settings', () => {
// Change snap interval to 30 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.locator('.fc-toolbar-title').click();
// Close the popover by pressing Escape
await page.keyboard.press('Escape');
// Verify localStorage was updated
const stored = await page.evaluate(() =>
@@ -54,7 +80,7 @@ test.describe('Calendar Settings', () => {
await expect(page.getByLabel('Snap Interval')).toContainText('30 min');
});
test('start time change is applied to calendar and rejects values >= end time', async ({
test('start time change is applied to calendar and rejects invalid values', async ({
page,
}) => {
await goToCalendar(page);
@@ -71,24 +97,23 @@ test.describe('Calendar Settings', () => {
// Change start time to 8 AM (valid)
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
await page.locator('.fc-toolbar-title').click();
// Calendar should no longer show hours before 8 AM
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
// Try to set start time to 6 PM (invalid: equals end time)
await openSettingsPopover(page);
// Try to set start time to 6 PM (invalid: equals end time) — should be rejected
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
// Should be rejected — start time stays at 8 AM
await expect(page.getByLabel('Start Time')).toContainText('8:00 AM');
// Close the popover
await page.keyboard.press('Escape');
// Calendar should no longer show hours before 8 AM
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
});
test('end time change is applied to calendar and rejects values <= start time', async ({
page,
}) => {
test('end time change is applied to calendar and rejects invalid values', async ({ page }) => {
await goToCalendar(page);
// Verify 19:00 slot exists with default end (24:00)
@@ -103,19 +128,20 @@ test.describe('Calendar Settings', () => {
// Change end time to 6 PM (valid)
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
await page.locator('.fc-toolbar-title').click();
// Calendar should no longer show hours at or after 6 PM
await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0);
// Try to set end time to 8 AM (invalid: equals start time)
await openSettingsPopover(page);
// Try to set end time to 8 AM (invalid: equals start time) — should be rejected
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
// Should be rejected — end time stays at 6 PM
await expect(page.getByLabel('End Time')).toContainText('6:00 PM');
// Close the popover
await page.keyboard.press('Escape');
// Calendar should no longer show hours at or after 6 PM
await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0);
});
test('grid scale affects number of calendar slots', async ({ page }) => {
@@ -128,19 +154,31 @@ test.describe('Calendar Settings', () => {
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.locator('.fc-toolbar-title').click();
await page.keyboard.press('Escape');
// Wait for FullCalendar to re-render with new slot count
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot').count();
expect(count).toBeLessThan(defaultSlotCount);
}).toPass({ timeout: 5000 });
const largerSlotCount = await page.locator('.fc-timegrid-slot').count();
expect(largerSlotCount).toBeLessThan(defaultSlotCount);
// Change to 5 min scale (should have many more slots)
// Navigate away and back to get a clean calendar mount
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await goToCalendar(page);
// Change to 5 min scale (many more slots)
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.locator('.fc-toolbar-title').click();
await page.keyboard.press('Escape');
const smallerSlotCount = await page.locator('.fc-timegrid-slot').count();
expect(smallerSlotCount).toBeGreaterThan(defaultSlotCount);
// Wait for FullCalendar to re-render with new slot count
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot').count();
expect(count).toBeGreaterThan(largerSlotCount);
}).toPass({ timeout: 5000 });
});
test('all settings persist across navigation', async ({ page }) => {
@@ -156,7 +194,9 @@ test.describe('Calendar Settings', () => {
await page.getByRole('option', { name: '10:00 PM' }).click();
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.locator('.fc-toolbar-title').click();
// Close the popover
await page.keyboard.press('Escape');
// Navigate away and back
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
@@ -170,3 +210,480 @@ test.describe('Calendar Settings', () => {
await expect(page.getByLabel('Grid Scale')).toContainText('30 min');
});
});
test.describe('Calendar Toolbar', () => {
test('prev and next buttons navigate the calendar', async ({ page }) => {
await goToCalendar(page);
// Use column headers to detect navigation (title only shows month which may not change)
const getHeaderTexts = async () => {
const headers = page.locator('.fc-col-header-cell');
return headers.allTextContents();
};
const initialHeaders = await getHeaderTexts();
// Click next
await page.getByRole('button', { name: 'Next', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
const nextHeaders = await getHeaderTexts();
expect(nextHeaders).not.toEqual(initialHeaders);
// Click prev — should go back to original
await page.getByRole('button', { name: 'Previous', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
const backHeaders = await getHeaderTexts();
expect(backHeaders).toEqual(initialHeaders);
});
test('today button returns to current week', async ({ page }) => {
await goToCalendar(page);
// Use column headers to detect navigation (title only shows month which may not change)
const getHeaderTexts = async () => {
const headers = page.locator('.fc-col-header-cell');
return headers.allTextContents();
};
const initialHeaders = await getHeaderTexts();
// Navigate away
await page.getByRole('button', { name: 'Next', exact: true }).click();
await page.getByRole('button', { name: 'Next', exact: true }).click();
const awayHeaders = await getHeaderTexts();
expect(awayHeaders).not.toEqual(initialHeaders);
// Click today
await page.getByRole('button', { name: 'today', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
const todayHeaders = await getHeaderTexts();
expect(todayHeaders).toEqual(initialHeaders);
});
test('view switcher toggles between week and day views', async ({ page }) => {
await goToCalendar(page);
// Default should be week view — verify multiple day columns exist
await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1);
// Switch to day view
await page.getByRole('tab', { name: 'day', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
// Day view should show exactly 1 day column
await expect(page.locator('.fc-col-header-cell')).toHaveCount(1);
// Switch back to week view
await page.getByRole('tab', { name: 'week', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
// Week view should show multiple day columns again
await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1);
});
});
test.describe('Visual Snapping', () => {
test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page);
});
test('snap interval of 1 minute allows fine-grained positioning', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set snap interval to 1 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '1 min' }).click();
await page.keyboard.press('Escape');
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Snap 1min test', '1h');
await goToCalendar(page);
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Get target slot at a non-15-min boundary time
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
// Drag event to a position offset from the 15-min boundary
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
const minutes = startDate.getMinutes();
// With 1-min snap, any minute value is valid (0-59)
expect(minutes).toBeGreaterThanOrEqual(0);
expect(minutes).toBeLessThanOrEqual(59);
});
test('snap interval of 60 minutes creates hour-aligned entries', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set snap interval to 60 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '1 hour' }).click();
await page.keyboard.press('Escape');
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Snap 60min test', '1h');
await goToCalendar(page);
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Get target slot
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
// Drag event
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
const minutes = startDate.getMinutes();
// With 60-min snap, minutes should be 0 (on the hour)
expect(minutes).toBe(0);
});
test('changing snap interval mid-session affects next drag', async ({ page, ctx }) => {
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Snap change test', '1h');
await goToCalendar(page);
// Set snap to 15 min
await openSettingsPopover(page);
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '15 min' }).click();
await page.keyboard.press('Escape');
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Drag event to 14:00 area
const targetSlot14 = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox14 = await targetSlot14.boundingBox();
expect(targetBox14).not.toBeNull();
const putResponsePromise1 = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox14!.x + targetBox14!.width / 2, targetBox14!.y + 5, {
steps: 10,
});
await page.mouse.up();
const putResponse1 = await putResponsePromise1;
expect(putResponse1.status()).toBe(200);
const body1 = await putResponse1.json();
const startDate1 = new Date(body1.data.start);
expect(startDate1.getMinutes() % 15).toBe(0);
// Wait for query re-fetch/re-renders to fully settle after drag
await page.waitForTimeout(1500);
// Change snap to 30 min
// Use Escape first to ensure no stale popover is open, then re-open
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await openSettingsPopover(page);
await page.waitForTimeout(300);
await page.getByLabel('Snap Interval').click({ force: true });
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Scroll the calendar so the 10:00 target area is visible
await scrollCalendarToTime(page, '09:00:00');
// Drag event to 10:00 area
const targetSlot10 = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first();
const targetBox10 = await targetSlot10.boundingBox();
expect(targetBox10).not.toBeNull();
const putResponsePromise2 = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox10!.x + targetBox10!.width / 2, targetBox10!.y + 5, {
steps: 10,
});
await page.mouse.up();
const putResponse2 = await putResponsePromise2;
expect(putResponse2.status()).toBe(200);
const body2 = await putResponse2.json();
const startDate2 = new Date(body2.data.start);
expect(startDate2.getMinutes() % 30).toBe(0);
});
test('snap with different grid scale (slot != snap)', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set grid scale to 30 min, snap to 5 min
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.keyboard.press('Escape');
// Wait for re-render with 30-min grid
await expect(async () => {
const slotCount = await page.locator('.fc-timegrid-slot-lane').count();
// 24 hours * 2 slots/hour = 48 slots for 30-min grid
expect(slotCount).toBeLessThanOrEqual(48);
}).toPass({ timeout: 5000 });
// Verify grid is 30-min (fewer slots than default 15-min)
const slotCount = await page.locator('.fc-timegrid-slot-lane').count();
// Default 15-min grid has 96 slots; 30-min grid should have 48
expect(slotCount).toBeLessThanOrEqual(48);
// Create a 1h time entry and go to calendar
await createBareTimeEntryViaApi(ctx, 'Grid snap test', '1h');
await goToCalendar(page);
// Re-apply settings since goToCalendar navigates
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.keyboard.press('Escape');
// Scroll so both the event (9:00) and target (14:00) are in viewport
await scrollCalendarToTime(page, '08:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Capture target coordinates after scroll is settled
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
// Snap is 5 min, so minutes should be divisible by 5
expect(startDate.getMinutes() % 5).toBe(0);
});
});
test.describe('Calendar Settings Effects', () => {
test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page);
});
test('start/end time hides slots outside visible range', async ({ page, ctx }) => {
// Create a time entry at 6 AM today
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 6, 0, 0);
const end = new Date(start.getTime() + 3600 * 1000); // 7 AM
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Early morning entry',
start: start.toISOString().replace(/\.\d{3}Z$/, 'Z'),
end: end.toISOString().replace(/\.\d{3}Z$/, 'Z'),
});
await goToCalendar(page);
// Verify 6 AM slot is visible with default settings
await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).not.toHaveCount(0);
// Set start time to 8 AM
await openSettingsPopover(page);
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
await page.keyboard.press('Escape');
// 6 AM slot should be hidden
await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).toHaveCount(0);
// 8 AM slot should be visible
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
});
test('grid scale affects event visual height proportionally', async ({ page, ctx }) => {
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Height test', '1h');
await goToCalendar(page);
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
await event.scrollIntoViewIfNeeded();
// Get event height with default 15-min grid scale
const box15 = await event.boundingBox();
expect(box15).not.toBeNull();
const height15 = box15!.height;
// Change grid scale to 60 min
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '1 hour' }).click();
await page.keyboard.press('Escape');
// Wait for re-render and scroll event into view
await event.scrollIntoViewIfNeeded();
await expect(async () => {
const box = await event.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).not.toBe(height15);
}).toPass({ timeout: 5000 });
const box60 = await event.boundingBox();
expect(box60).not.toBeNull();
const height60 = box60!.height;
// Event should appear smaller with larger grid scale
expect(height15).toBeGreaterThan(height60);
});
test('snap interval affects drag granularity', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set snap to 30 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Drag granularity test', '1h');
await goToCalendar(page);
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Get target slot
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
// Drag event
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
const minutes = startDate.getMinutes();
// With 30-min snap, minutes should be 0 or 30
expect(minutes % 30).toBe(0);
});
test('settings apply immediately without page reload', async ({ page }) => {
await goToCalendar(page);
// Count slots with default grid scale (15 min)
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
// Change grid scale to 30 min
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Verify slot count changed without navigation
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot').count();
expect(count).toBeLessThan(defaultSlotCount);
}).toPass({ timeout: 5000 });
// Wait for FullCalendar to fully stabilize after re-render
await page.waitForTimeout(2000);
await expect(page.locator('.fc')).toBeVisible();
// Change start time to 8 AM
// FullCalendar re-render from grid scale change can make popover elements unstable.
// Retry the open+click sequence if it fails.
await expect(async () => {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await page.getByRole('button', { name: 'Calendar settings' }).click();
await expect(page.getByText('Calendar Settings')).toBeVisible();
const startTimeBtn = page.getByLabel('Start Time');
await expect(startTimeBtn).toBeVisible();
await startTimeBtn.click({ timeout: 3000 });
}).toPass({ timeout: 10000 });
await page.getByRole('option', { name: '8:00 AM' }).click();
await page.keyboard.press('Escape');
// Verify 7 AM slot is hidden without reload
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot[data-time="07:00:00"]').count();
expect(count).toBe(0);
}).toPass({ timeout: 5000 });
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -132,6 +132,80 @@ test('test that deleting a client via actions menu works', async ({ page, ctx })
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
});
// =============================================
// Context Menu Tests
// =============================================
test('test that client context menu edit updates the client', async ({ page, ctx }) => {
const clientName = 'CtxEditClient ' + Math.floor(1 + Math.random() * 10000);
const updatedName = 'CtxUpdatedClient ' + Math.floor(1 + Math.random() * 10000);
await createClientViaApi(ctx, { name: clientName });
await goToClientsOverview(page);
const row = page.getByRole('row').filter({ hasText: clientName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByPlaceholder('Client Name').fill(updatedName);
await Promise.all([
page.getByRole('button', { name: 'Update Client' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/clients') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
]);
await expect(page.getByTestId('client_table')).toContainText(updatedName);
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
});
test('test that client context menu archive archives the client', async ({ page, ctx }) => {
const clientName = 'CtxArchiveClient ' + Math.floor(1 + Math.random() * 10000);
await createClientViaApi(ctx, { name: clientName });
await goToClientsOverview(page);
const row = page.getByRole('row').filter({ hasText: clientName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/clients') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('menuitem', { name: 'Archive' }).click(),
]);
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
});
test('test that client context menu delete deletes the client', async ({ page, ctx }) => {
const clientName = 'CtxDeleteClient ' + Math.floor(1 + Math.random() * 10000);
await createClientViaApi(ctx, { name: clientName });
await goToClientsOverview(page);
const row = page.getByRole('row').filter({ hasText: clientName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/clients') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
page.getByRole('menuitem', { name: 'Delete' }).click(),
]);
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
});
// =============================================
// Sorting Tests
// =============================================

View File

@@ -496,6 +496,158 @@ test('test that organization owner cannot be deleted', async ({ page }) => {
await expect(page.getByRole('row').filter({ hasText: 'Owner' })).toBeVisible();
});
// =============================================
// Context Menu Tests
// =============================================
test('test that member context menu edit updates the member billable rate', async ({
page,
ctx,
}) => {
const memberName = 'CtxEditMember ' + Math.floor(1 + Math.random() * 10000);
await createPlaceholderMemberViaImportApi(ctx, memberName);
await goToMembersPage(page);
const row = page.getByRole('row').filter({ hasText: memberName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
// Change billable rate from default to custom
const billableRateSelect = page.getByRole('dialog').getByRole('combobox').last();
await billableRateSelect.click();
await page.getByRole('option', { name: 'Custom Rate' }).click();
// Set a custom billable rate
await page.getByPlaceholder('Billable Rate').fill('150');
// Click Update Member — confirmation dialog should appear
await page.getByRole('button', { name: 'Update Member' }).click();
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
// Confirm the billable rate change
await Promise.all([
page.getByRole('button', { name: 'Yes, update existing time entries' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/members/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
]);
// Verify dialog closed
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('test that member context menu merge merges the member', async ({ page, ctx }) => {
const memberName = 'CtxMergeMember ' + Math.floor(1 + Math.random() * 10000);
await createPlaceholderMemberViaImportApi(ctx, memberName);
await goToMembersPage(page);
const row = page.getByRole('row').filter({ hasText: memberName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Merge' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible();
// Select the first available member as merge target
await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click();
const firstOption = page.getByRole('option').first();
await expect(firstOption).toBeVisible({ timeout: 10000 });
await firstOption.click();
// Submit merge
await Promise.all([
page.getByRole('button', { name: 'Merge Member' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/member/') &&
response.url().includes('/merge-into') &&
response.ok()
),
]);
// Verify placeholder member is no longer visible
await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible();
await expect(page.getByRole('main').getByText(memberName)).not.toBeVisible();
});
test('test that member context menu deactivate deactivates the member', async ({
page,
browser,
}) => {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `member+${memberId}@deactivate.test`;
const memberName = 'Deactivate Target';
// Invite and accept a new Employee member
await inviteAndAcceptMember(page, browser, memberName, memberEmail, 'Employee');
await goToMembersPage(page);
const row = page.getByRole('row').filter({ hasText: memberName }).first();
await expect(row).toBeVisible();
// Open context menu and click Deactivate
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Deactivate' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Deactivate User' })).toBeVisible();
// Confirm deactivation
await Promise.all([
page.getByRole('button', { name: 'Deactivate' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/make-placeholder') &&
response.request().method() === 'POST' &&
response.ok()
),
]);
// Verify dialog closed and member role changed to Placeholder
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(row.getByText('Placeholder', { exact: true })).toBeVisible();
});
test('test that member context menu delete deletes the member', async ({ page, ctx }) => {
const memberName = 'CtxDeleteMember ' + Math.floor(1 + Math.random() * 10000);
await createPlaceholderMemberViaImportApi(ctx, memberName);
await goToMembersPage(page);
const row = page.getByRole('row').filter({ hasText: memberName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible();
// Check the confirmation checkbox
await page.getByRole('checkbox').click();
// Click Delete Member button and wait for API response
await Promise.all([
page.getByRole('button', { name: 'Delete Member' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/members/') &&
response.request().method() === 'DELETE' &&
response.ok()
),
]);
// Verify modal closed and member removed from table
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByRole('main').getByText(memberName)).not.toBeVisible();
});
// =============================================
// Invitations Tab Tests
// =============================================

View File

@@ -800,6 +800,81 @@ test('test that editing a task name on the project detail page works', async ({
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
});
// =============================================
// Context Menu Tests
// =============================================
test('test that project context menu edit updates the project', async ({ page, ctx }) => {
const projectName = 'CtxEditProject ' + Math.floor(1 + Math.random() * 10000);
const updatedName = 'CtxUpdatedProject ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: projectName });
await goToProjectsOverview(page);
const row = page.getByRole('row').filter({ hasText: projectName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByPlaceholder('Project Name').fill(updatedName);
await Promise.all([
page.getByRole('button', { name: 'Update Project' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/projects/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
]);
await expect(page.getByTestId('project_table')).toContainText(updatedName);
await expect(page.getByTestId('project_table')).not.toContainText(projectName);
});
test('test that project context menu archive archives the project', async ({ page, ctx }) => {
const projectName = 'CtxArchiveProject ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: projectName });
await goToProjectsOverview(page);
const row = page.getByRole('row').filter({ hasText: projectName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/projects') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('menuitem', { name: 'Archive' }).click(),
]);
// After archiving, the project stays visible (default filter is 'all') but status changes to 'Archived'
await expect(row).toContainText('Archived');
});
test('test that project context menu delete deletes the project', async ({ page, ctx }) => {
const projectName = 'CtxDeleteProject ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: projectName });
await goToProjectsOverview(page);
const row = page.getByRole('row').filter({ hasText: projectName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/projects') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
page.getByRole('menuitem', { name: 'Delete' }).click(),
]);
await expect(page.getByTestId('project_table')).not.toContainText(projectName);
});
// =============================================
// Employee Permission Tests
// =============================================

View File

@@ -1,6 +1,10 @@
import { expect } from '@playwright/test';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
dayjs.extend(utc);
import {
createProjectViaApi,
createClientViaApi,
@@ -11,6 +15,7 @@ import {
createBillableProjectViaApi,
createTimeEntryWithBillableStatusViaApi,
createTagViaApi,
createReportViaApi,
} from './utils/api';
import {
goToReporting,
@@ -766,6 +771,97 @@ test('test that updating expiration date on already-public report works', async
expect(returnedDate.getTime()).toBeGreaterThan(now.getTime());
});
test('test that clearing the expiration date on a report works', async ({ page, ctx }) => {
const reportName = 'ClearExpReport ' + Math.floor(Math.random() * 10000);
// Create a public report with an expiration date via API
await createReportViaApi(ctx, {
name: reportName,
is_public: true,
public_until: dayjs().add(1, 'month').utc().format('YYYY-MM-DDTHH:mm:ss[Z]'),
});
// Go to shared reports and edit the report
await goToReportingShared(page);
await expect(page.getByText(reportName)).toBeVisible();
await page
.getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
.click();
await page.getByRole('menuitem', { name: /^Edit Report/ }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The date picker should show a date (not "Pick a date")
await expect(
page.getByRole('dialog').getByRole('button', { name: 'Pick a date' })
).not.toBeVisible();
// Click the clear button (X icon) to remove the expiration date
const clearButton = page
.getByRole('dialog')
.locator('[role="button"]')
.filter({ has: page.locator('svg.lucide-x') });
await expect(clearButton).toBeVisible();
await clearButton.click();
// The date picker should now show "Pick a date"
await expect(
page.getByRole('dialog').getByRole('button', { name: 'Pick a date' })
).toBeVisible();
// The clear button should no longer be visible
await expect(clearButton).not.toBeVisible();
// Update the report and verify public_until is null
const [updateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reports/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Report' }).click(),
]);
const updateBody = await updateResponse.json();
expect(updateBody.data.public_until).toBeNull();
});
test('test that date picker clear button is not visible when no date is set', async ({
page,
ctx,
}) => {
const reportName = 'NoClearReport ' + Math.floor(Math.random() * 10000);
// Create a public report without an expiration date via API
await createReportViaApi(ctx, {
name: reportName,
is_public: true,
public_until: null,
});
// Go to shared reports and edit the report
await goToReportingShared(page);
await expect(page.getByText(reportName)).toBeVisible();
await page
.getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
.click();
await page.getByRole('menuitem', { name: /^Edit Report/ }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The date picker should show "Pick a date"
await expect(
page.getByRole('dialog').getByRole('button', { name: 'Pick a date' })
).toBeVisible();
// The clear button should NOT be visible
const clearButton = page
.getByRole('dialog')
.locator('[role="button"]')
.filter({ has: page.locator('svg.lucide-x') });
await expect(clearButton).not.toBeVisible();
});
// ──────────────────────────────────────────────────
// Shared Report Cost Column Tests
// ──────────────────────────────────────────────────

View File

@@ -90,6 +90,59 @@ test('test that multiple tags can be created via API and displayed in the table'
await expect(page.getByTestId('tag_table')).toContainText(tagName2);
});
// =============================================
// Context Menu Tests
// =============================================
test('test that tag context menu edit updates the tag', async ({ page, ctx }) => {
const tagName = 'CtxEditTag ' + Math.floor(1 + Math.random() * 10000);
const updatedName = 'CtxUpdatedTag ' + Math.floor(1 + Math.random() * 10000);
await createTagViaApi(ctx, { name: tagName });
await goToTagsOverview(page);
const row = page.getByRole('row').filter({ hasText: tagName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByPlaceholder('Tag Name').fill(updatedName);
await Promise.all([
page.getByRole('button', { name: 'Update Tag' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/tags') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
]);
await expect(page.getByTestId('tag_table')).toContainText(updatedName);
await expect(page.getByTestId('tag_table')).not.toContainText(tagName);
});
test('test that tag context menu delete deletes the tag', async ({ page, ctx }) => {
const tagName = 'CtxDeleteTag ' + Math.floor(1 + Math.random() * 10000);
await createTagViaApi(ctx, { name: tagName });
await goToTagsOverview(page);
const row = page.getByRole('row').filter({ hasText: tagName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/tags') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
page.getByRole('menuitem', { name: 'Delete' }).click(),
]);
await expect(page.getByTestId('tag_table')).not.toContainText(tagName);
});
// =============================================
// Sorting Tests
// =============================================

View File

@@ -15,6 +15,7 @@ import {
createBareTimeEntryViaApi,
createTimeEntryViaApi,
updateOrganizationCurrencyViaWeb,
updateOrganizationSettingViaApi,
} from './utils/api';
// Date picker button name patterns for different date formats
@@ -608,7 +609,7 @@ test('test that billable icon shows dollar sign for USD currency on time entry r
page,
ctx,
}) => {
await updateOrganizationCurrencyViaWeb(ctx, 'USD');
await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');
await goToTimeOverview(page);
await createEmptyTimeEntry(page);
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
@@ -621,7 +622,7 @@ test('test that billable icon shows euro sign for EUR currency on time entry row
page,
ctx,
}) => {
await updateOrganizationCurrencyViaWeb(ctx, 'EUR');
await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');
await goToTimeOverview(page);
await createEmptyTimeEntry(page);
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
@@ -963,7 +964,12 @@ test('test that natural language duration input works in create modal', async ({
expect(createBody.data.duration).toBe(9000);
});
test('test that decimal duration input works in create modal', async ({ page }) => {
test('test that decimal duration input works in create modal', async ({ page, ctx }) => {
// Ensure comma-point format so "1.5h" uses period as decimal
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'hours-minutes',
number_format: 'comma-point',
});
await goToTimeOverview(page);
// Open the create modal
@@ -978,7 +984,6 @@ test('test that decimal duration input works in create modal', async ({ page })
.fill('Decimal duration test');
// Test decimal duration input "1.5h" (should be interpreted as 1.5 hours = 90 minutes)
// Note: parse-duration library requires a unit suffix for decimal values
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
await durationInput.fill('1.5h');
await durationInput.press('Tab');
@@ -997,6 +1002,508 @@ test('test that decimal duration input works in create modal', async ({ page })
expect(createBody.data.duration).toBe(5400);
});
test('test that decimal duration with comma number format does not corrupt on blur in edit modal', async ({
page,
ctx,
}) => {
// Set organization to decimal interval format with European number format (comma as decimal separator)
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'decimal',
number_format: 'point-comma',
});
// Create a 1-hour time entry
await createBareTimeEntryViaApi(ctx, 'Decimal blur test', '1h');
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
const newTimeEntry = timeEntryRows.first();
// Open edit modal via the actions dropdown
const actionsDropdown = newTimeEntry
.getByRole('button', { name: 'Actions for the time entry' })
.first();
await actionsDropdown.click();
await page.getByTestId('time_entry_edit').click();
await expect(page.getByRole('dialog')).toBeVisible();
// The duration input should show "1,00 h" (decimal format with comma)
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
await expect(durationInput).toHaveValue('1,00 h');
// Click on the duration input and blur it without changing the value
await durationInput.click();
await durationInput.press('Tab');
// After blur, the value should remain "1,00 h" and NOT become "100,00 h"
await expect(durationInput).toHaveValue('1,00 h');
// Submit and verify the duration is still 3600 seconds (1 hour)
const [updateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Time Entry' }).click(),
]);
const updateBody = await updateResponse.json();
expect(updateBody.data.duration).toBe(3600);
// Reset organization settings
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'hours-minutes',
number_format: 'comma-point',
});
});
test('test that typing bare decimal 1,5 in edit modal is interpreted as 1.5 hours', async ({
page,
ctx,
}) => {
// Set organization to decimal interval format with European number format
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'decimal',
number_format: 'point-comma',
});
// Create a 1-hour time entry
await createBareTimeEntryViaApi(ctx, 'Bare decimal test', '1h');
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
const newTimeEntry = timeEntryRows.first();
// Open edit modal
const actionsDropdown = newTimeEntry
.getByRole('button', { name: 'Actions for the time entry' })
.first();
await actionsDropdown.click();
await page.getByTestId('time_entry_edit').click();
await expect(page.getByRole('dialog')).toBeVisible();
// Type "1,5" (bare decimal without "h" suffix) — should be interpreted as 1.5 hours
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
await durationInput.fill('1,5');
await durationInput.press('Tab');
// Should display as "1,50 h" (1.5 hours formatted in point-comma locale)
await expect(durationInput).toHaveValue('1,50 h');
// Submit and verify the duration is 5400 seconds (1.5 hours)
const [updateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Time Entry' }).click(),
]);
const updateBody = await updateResponse.json();
expect(updateBody.data.duration).toBe(5400);
// Reset organization settings
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'hours-minutes',
number_format: 'comma-point',
});
});
test('test that typing bare decimal 1.5 in edit modal is interpreted as 1.5 hours', async ({
page,
ctx,
}) => {
// Set organization to decimal interval format with default number format
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'decimal',
number_format: 'comma-point',
});
// Create a 1-hour time entry
await createBareTimeEntryViaApi(ctx, 'Bare decimal dot test', '1h');
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
const newTimeEntry = timeEntryRows.first();
// Open edit modal
const actionsDropdown = newTimeEntry
.getByRole('button', { name: 'Actions for the time entry' })
.first();
await actionsDropdown.click();
await page.getByTestId('time_entry_edit').click();
await expect(page.getByRole('dialog')).toBeVisible();
// Type "1.5" (bare decimal with period) — should be interpreted as 1.5 hours
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
await durationInput.fill('1.5');
await durationInput.press('Tab');
// Should display as "1.50 h" (1.5 hours formatted in comma-point locale)
await expect(durationInput).toHaveValue('1.50 h');
// Submit and verify the duration is 5400 seconds (1.5 hours)
const [updateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Time Entry' }).click(),
]);
const updateBody = await updateResponse.json();
expect(updateBody.data.duration).toBe(5400);
// Reset organization settings
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'hours-minutes',
number_format: 'comma-point',
});
});
test('test that decimal duration with space-comma number format does not corrupt on blur in edit modal', async ({
page,
ctx,
}) => {
// Set organization to decimal interval format with space-comma number format
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'decimal',
number_format: 'space-comma',
});
// Create a 1-hour time entry
await createBareTimeEntryViaApi(ctx, 'Space-comma blur test', '1h');
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
const newTimeEntry = timeEntryRows.first();
// Open edit modal
const actionsDropdown = newTimeEntry
.getByRole('button', { name: 'Actions for the time entry' })
.first();
await actionsDropdown.click();
await page.getByTestId('time_entry_edit').click();
await expect(page.getByRole('dialog')).toBeVisible();
// The duration input should show "1,00 h" (space-comma uses comma as decimal)
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
await expect(durationInput).toHaveValue('1,00 h');
// Blur without changing the value
await durationInput.click();
await durationInput.press('Tab');
// Should remain "1,00 h"
await expect(durationInput).toHaveValue('1,00 h');
// Submit and verify the duration is still 3600 seconds
const [updateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Time Entry' }).click(),
]);
const updateBody = await updateResponse.json();
expect(updateBody.data.duration).toBe(3600);
// Reset organization settings
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'hours-minutes',
number_format: 'comma-point',
});
});
test('test that bare integer in edit modal is interpreted as minutes', async ({ page, ctx }) => {
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'hours-minutes',
number_format: 'comma-point',
});
await createBareTimeEntryViaApi(ctx, 'Bare integer test', '1h');
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
const newTimeEntry = timeEntryRows.first();
// Open edit modal
const actionsDropdown = newTimeEntry
.getByRole('button', { name: 'Actions for the time entry' })
.first();
await actionsDropdown.click();
await page.getByTestId('time_entry_edit').click();
await expect(page.getByRole('dialog')).toBeVisible();
// Type "30" — should be interpreted as 30 minutes
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
await durationInput.fill('30');
await durationInput.press('Tab');
// Should display as "0h 30min"
await expect(durationInput).toHaveValue('0h 30min');
// Submit and verify the duration is 1800 seconds (30 minutes)
const [updateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Time Entry' }).click(),
]);
const updateBody = await updateResponse.json();
expect(updateBody.data.duration).toBe(1800);
});
test('test that bare integer in edit modal with decimal format is interpreted as hours', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'decimal',
number_format: 'comma-point',
});
await createBareTimeEntryViaApi(ctx, 'Bare integer decimal test', '1h');
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
const newTimeEntry = timeEntryRows.first();
// Open edit modal
const actionsDropdown = newTimeEntry
.getByRole('button', { name: 'Actions for the time entry' })
.first();
await actionsDropdown.click();
await page.getByTestId('time_entry_edit').click();
await expect(page.getByRole('dialog')).toBeVisible();
// Type "2" — with decimal format, should be interpreted as 2 hours
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
await durationInput.fill('2');
await durationInput.press('Tab');
// Should display as "2.00 h"
await expect(durationInput).toHaveValue('2.00 h');
// Submit and verify the duration is 7200 seconds (2 hours)
const [updateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Time Entry' }).click(),
]);
const updateBody = await updateResponse.json();
expect(updateBody.data.duration).toBe(7200);
// Reset organization settings
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'hours-minutes',
number_format: 'comma-point',
});
});
test('test that HH:MM input in edit modal works', async ({ page, ctx }) => {
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'hours-minutes',
number_format: 'comma-point',
});
await createBareTimeEntryViaApi(ctx, 'HH:MM test', '1h');
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
const newTimeEntry = timeEntryRows.first();
// Open edit modal
const actionsDropdown = newTimeEntry
.getByRole('button', { name: 'Actions for the time entry' })
.first();
await actionsDropdown.click();
await page.getByTestId('time_entry_edit').click();
await expect(page.getByRole('dialog')).toBeVisible();
// Type "1:30" — should be interpreted as 1 hour 30 minutes
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
await durationInput.fill('1:30');
await durationInput.press('Tab');
// Should display as "1h 30min"
await expect(durationInput).toHaveValue('1h 30min');
// Submit and verify the duration is 5400 seconds (1.5 hours)
const [updateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Time Entry' }).click(),
]);
const updateBody = await updateResponse.json();
expect(updateBody.data.duration).toBe(5400);
});
test('test that bare integer in inline duration input is interpreted as minutes', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'hours-minutes',
number_format: 'comma-point',
});
await createBareTimeEntryViaApi(ctx, 'Inline bare integer test', '1h');
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
const newTimeEntry = timeEntryRows.first();
// Type "45" in the inline duration input — should be 45 minutes
const durationInput = newTimeEntry.getByTestId('time_entry_duration_input').first();
await durationInput.click();
await durationInput.fill('45');
const [updateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
durationInput.press('Tab'),
]);
const updateBody = await updateResponse.json();
expect(updateBody.data.duration).toBe(2700);
});
test('test that bare integer in inline duration input with decimal format is interpreted as hours', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'decimal',
number_format: 'comma-point',
});
await createBareTimeEntryViaApi(ctx, 'Inline bare integer decimal test', '1h');
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
const newTimeEntry = timeEntryRows.first();
// Type "3" in the inline duration input — with decimal format, should be 3 hours
const durationInput = newTimeEntry.getByTestId('time_entry_duration_input').first();
await durationInput.click();
await durationInput.fill('3');
const [updateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
durationInput.press('Tab'),
]);
const updateBody = await updateResponse.json();
expect(updateBody.data.duration).toBe(10800);
// Reset organization settings
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'hours-minutes',
number_format: 'comma-point',
});
});
test('test that bare integer in create modal is interpreted as minutes', async ({ page, ctx }) => {
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'hours-minutes',
number_format: 'comma-point',
});
await goToTimeOverview(page);
// Open the create modal
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill('Bare integer create test');
// Type "30" — should be interpreted as 30 minutes
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
await durationInput.fill('30');
await durationInput.press('Tab');
await expect(durationInput).toHaveValue('0h 30min');
const [createResponse] = await Promise.all([
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
page.getByRole('button', { name: 'Create Time Entry' }).click(),
]);
const createBody = await createResponse.json();
expect(createBody.data.duration).toBe(1800);
});
test('test that bare integer in create modal with decimal format is interpreted as hours', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'decimal',
number_format: 'comma-point',
});
await goToTimeOverview(page);
// Open the create modal
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill('Bare integer decimal create test');
// Type "2" — with decimal format, should be interpreted as 2 hours
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
await durationInput.fill('2');
await durationInput.press('Tab');
await expect(durationInput).toHaveValue('2.00 h');
const [createResponse] = await Promise.all([
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
page.getByRole('button', { name: 'Create Time Entry' }).click(),
]);
const createBody = await createResponse.json();
expect(createBody.data.duration).toBe(7200);
// Reset organization settings
await updateOrganizationSettingViaApi(ctx, {
interval_format: 'hours-minutes',
number_format: 'comma-point',
});
});
test('test that project selection works in create modal', async ({ page, ctx }) => {
const projectName = 'Create Modal Project ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: projectName });
@@ -1535,3 +2042,228 @@ test.describe('Employee Time Entry Isolation', () => {
await expect(timeEntryRow).not.toBeVisible();
});
});
// =============================================
// Context Menu Tests
// =============================================
async function openTimeEntryContextMenu(page: Page, description: string) {
const row = page
.locator('[data-testid="time_entry_row"]')
.filter({ hasText: description })
.first();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
}
test('test that context menu appears with correct items on time entry row', async ({
page,
ctx,
}) => {
const description = 'Context menu items test ' + Math.floor(1 + Math.random() * 10000);
await createBareTimeEntryViaApi(ctx, description, '1h');
await goToTimeOverview(page);
await openTimeEntryContextMenu(page, description);
await expect(page.getByRole('menuitem', { name: 'Continue' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
});
test('test that context menu edit opens the edit modal', async ({ page, ctx }) => {
const description = 'Context edit test ' + Math.floor(1 + Math.random() * 10000);
await createBareTimeEntryViaApi(ctx, description, '1h');
await goToTimeOverview(page);
await openTimeEntryContextMenu(page, description);
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('dialog').getByPlaceholder('What did you work on?')).toHaveValue(
description
);
});
test('test that context menu duplicate creates a copy', async ({ page, ctx }) => {
const description = 'Context dup test ' + Math.floor(1 + Math.random() * 10000);
const project = await createProjectViaApi(ctx, {
name: 'Dup Project ' + Math.floor(1 + Math.random() * 10000),
is_billable: true,
});
await createTimeEntryViaApi(ctx, {
description,
duration: '1h',
projectId: project.id,
billable: true,
});
await goToTimeOverview(page);
await openTimeEntryContextMenu(page, description);
const [createResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'POST' &&
response.status() === 201
),
page.getByRole('menuitem', { name: 'Duplicate' }).click(),
]);
const body = await createResponse.json();
expect(body.data.description).toBe(description);
expect(body.data.project_id).toBe(project.id);
expect(body.data.billable).toBe(true);
});
test('test that context menu continue starts a new time entry', async ({ page, ctx }) => {
const description = 'Context continue test ' + Math.floor(1 + Math.random() * 10000);
const project = await createProjectViaApi(ctx, {
name: 'Continue Project ' + Math.floor(1 + Math.random() * 10000),
is_billable: false,
});
await createTimeEntryViaApi(ctx, {
description,
duration: '1h',
projectId: project.id,
});
await goToTimeOverview(page);
await openTimeEntryContextMenu(page, description);
const [createResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'POST' &&
response.status() === 201
),
page.getByRole('menuitem', { name: 'Continue' }).click(),
]);
const body = await createResponse.json();
expect(body.data.description).toBe(description);
expect(body.data.project_id).toBe(project.id);
expect(body.data.end).toBeNull();
});
test('test that context menu delete removes the time entry', async ({ page, ctx }) => {
const description = 'Context delete test ' + Math.floor(1 + Math.random() * 10000);
await createBareTimeEntryViaApi(ctx, description, '1h');
await goToTimeOverview(page);
await openTimeEntryContextMenu(page, description);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') && response.request().method() === 'DELETE'
),
page.getByRole('menuitem', { name: 'Delete' }).click(),
]);
await expect(
page.locator('[data-testid="time_entry_row"]').filter({ hasText: description })
).not.toBeVisible();
});
test('test that aggregate row context menu shows only Continue and Delete', async ({
page,
ctx,
}) => {
const description = 'Context agg items ' + Math.floor(1 + Math.random() * 10000);
await createBareTimeEntryViaApi(ctx, description, '1h');
await createBareTimeEntryViaApi(ctx, description, '30min');
await goToTimeOverview(page);
const aggregateRow = page
.locator('[data-testid="time_entry_row"]')
.filter({ hasText: description })
.first();
await aggregateRow.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Continue' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Edit' })).not.toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).not.toBeVisible();
});
test('test that aggregate row context menu continue starts a new time entry', async ({
page,
ctx,
}) => {
const description = 'Context agg continue ' + Math.floor(1 + Math.random() * 10000);
const project = await createProjectViaApi(ctx, {
name: 'Agg Continue Project ' + Math.floor(1 + Math.random() * 10000),
is_billable: false,
});
await createTimeEntryViaApi(ctx, {
description,
duration: '1h',
projectId: project.id,
});
await createTimeEntryViaApi(ctx, {
description,
duration: '30min',
projectId: project.id,
});
await goToTimeOverview(page);
const aggregateRow = page
.locator('[data-testid="time_entry_row"]')
.filter({ hasText: description })
.first();
await aggregateRow.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
const [createResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'POST' &&
response.status() === 201
),
page.getByRole('menuitem', { name: 'Continue' }).click(),
]);
const body = await createResponse.json();
expect(body.data.description).toBe(description);
expect(body.data.project_id).toBe(project.id);
expect(body.data.end).toBeNull();
});
test('test that aggregate row context menu delete removes all grouped entries', async ({
page,
ctx,
}) => {
const description = 'Context agg delete ' + Math.floor(1 + Math.random() * 10000);
await createBareTimeEntryViaApi(ctx, description, '1h');
await createBareTimeEntryViaApi(ctx, description, '30min');
await goToTimeOverview(page);
// The aggregate row groups entries with same description
const aggregateRow = page
.locator('[data-testid="time_entry_row"]')
.filter({ hasText: description })
.first();
await aggregateRow.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') && response.request().method() === 'DELETE'
),
page.getByRole('menuitem', { name: 'Delete' }).click(),
]);
await expect(
page.locator('[data-testid="time_entry_row"]').filter({ hasText: description })
).not.toBeVisible();
});

View File

@@ -30,7 +30,7 @@ test('test that starting and stopping a timer without description and project wo
});
test('test that billable icon shows dollar sign for USD currency', async ({ page, ctx }) => {
await updateOrganizationCurrencyViaWeb(ctx, 'USD');
await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');
await goToDashboard(page);
await page.waitForLoadState('networkidle');
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
@@ -39,7 +39,7 @@ test('test that billable icon shows dollar sign for USD currency', async ({ page
});
test('test that billable icon shows euro sign for EUR currency', async ({ page, ctx }) => {
await updateOrganizationCurrencyViaWeb(ctx, 'EUR');
await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');
await goToDashboard(page);
await page.waitForLoadState('networkidle');
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();

View File

@@ -16,12 +16,59 @@ export interface TestContext {
// Auth helpers
// ──────────────────────────────────────────────────
async function getApiHeaders(page: Page): Promise<Record<string, string>> {
const cookies = await page.context().cookies();
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
/**
* Create a Passport API token by calling the token endpoint from the browser.
*
* The browser's native fetch includes the laravel_token cookie (set by
* CreateFreshApiToken during the dashboard page load), so authentication
* is handled by the browser's own cookie jar. The returned Bearer token is
* then used for all subsequent API calls, making them independent of cookie state.
*
* If the first attempt returns 401 (Octane hasn't fully committed the session yet),
* we reload the page to trigger a fresh CreateFreshApiToken and retry.
*/
async function createApiToken(page: Page): Promise<string> {
for (let attempt = 0; attempt < 3; attempt++) {
const result = await page.evaluate(async (baseUrl) => {
const xsrfCookie = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='));
const xsrfToken = xsrfCookie
? decodeURIComponent(xsrfCookie.split('=').slice(1).join('='))
: '';
const res = await fetch(`${baseUrl}/api/v1/users/me/api-tokens`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-XSRF-TOKEN': xsrfToken,
},
body: JSON.stringify({ name: 'playwright-test' }),
});
if (!res.ok) {
return null;
}
const body = await res.json();
return body.data.access_token as string;
}, PLAYWRIGHT_BASE_URL);
if (result) {
return result;
}
// Reload to get a fresh laravel_token cookie and retry.
// networkidle gives Octane time to fully commit the session.
await page.reload({ waitUntil: 'networkidle' });
}
throw new Error('Failed to create API token after retries');
}
function bearerHeaders(token: string): Record<string, string> {
return {
Accept: 'application/json',
...(xsrfCookie ? { 'X-XSRF-TOKEN': decodeURIComponent(xsrfCookie.value) } : {}),
Authorization: `Bearer ${token}`,
};
}
@@ -30,8 +77,10 @@ async function getApiHeaders(page: Page): Promise<Record<string, string>> {
// ──────────────────────────────────────────────────
export async function setupTestContext(page: Page): Promise<TestContext> {
const token = await createApiToken(page);
const request = page.request;
const headers = await getApiHeaders(page);
const headers = bearerHeaders(token);
const orgId = await getOrganizationId(request, headers);
const memberId = await getCurrentMemberId(request, orgId, headers);
return { request: createAuthenticatedRequest(request, headers), orgId, memberId };
@@ -424,6 +473,25 @@ export async function createTimeEntryWithTagViaApi(
return { tag, entry };
}
export async function createRunningTimeEntryViaApi(ctx: TestContext, description: string) {
const start = new Date();
start.setMinutes(start.getMinutes() - 10);
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
{
data: {
member_id: ctx.memberId,
start: formatTimestamp(start),
description,
billable: false,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; start: string; end: null; description: string };
}
export async function createBareTimeEntryViaApi(
ctx: TestContext,
description: string,
@@ -491,11 +559,17 @@ export async function updateOrganizationSettingViaApi(
}
export async function updateOrganizationCurrencyViaWeb(
page: Page,
ctx: TestContext,
currency: string,
name: string = 'Test Organization'
) {
const response = await ctx.request.put(`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, {
const cookies = await page.context().cookies();
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
const response = await page.request.put(`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, {
headers: { 'X-XSRF-TOKEN': xsrfToken },
data: { name, currency },
});
expect(response.status()).toBe(200);
@@ -529,7 +603,164 @@ export async function getInvitationsViaApi(ctx: TestContext) {
const response = await ctx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invitations`
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data as Array<{ id: string; email: string; role: string }>;
}
// ──────────────────────────────────────────────────
// Timestamp-based time entry helpers
// ──────────────────────────────────────────────────
export async function createTimeEntryWithTimestampsViaApi(
ctx: TestContext,
data: {
description?: string;
start: string;
end: string;
projectId?: string | null;
taskId?: string | null;
tags?: string[];
billable?: boolean;
}
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
{
data: {
member_id: ctx.memberId,
start: data.start,
end: data.end,
description: data.description ?? '',
project_id: data.projectId ?? null,
task_id: data.taskId ?? null,
tags: data.tags ?? [],
billable: data.billable ?? false,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; start: string; end: string; description: string };
}
// ──────────────────────────────────────────────────
// User profile helpers
// ──────────────────────────────────────────────────
export async function updateUserProfileViaWeb(
page: Page,
settings: { timezone?: string; week_start?: string }
) {
// Read user info from Inertia's data-page attribute on the root element
const userInfo = await page.evaluate(() => {
// Try Inertia's data-page attribute (stores initial page props as JSON)
const appEl = document.getElementById('app');
if (appEl) {
const dataPage = appEl.getAttribute('data-page');
if (dataPage) {
try {
const parsed = JSON.parse(dataPage);
const user = parsed?.props?.auth?.user;
if (user) {
return {
name: user.name,
email: user.email,
timezone: user.timezone,
week_start: user.week_start,
};
}
} catch {
// JSON parse failed
}
}
}
return null;
});
if (!userInfo) throw new Error('Could not read user info from Inertia data-page attribute');
const cookies = await page.context().cookies();
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
const response = await page.request.put(`${PLAYWRIGHT_BASE_URL}/user/profile-information`, {
headers: {
'X-XSRF-TOKEN': xsrfToken,
'Content-Type': 'application/json',
Accept: 'application/json',
},
data: {
name: userInfo.name,
email: userInfo.email,
timezone: settings.timezone ?? userInfo.timezone,
week_start: settings.week_start ?? userInfo.week_start,
},
});
expect(response.status()).toBe(200);
}
// ──────────────────────────────────────────────────
// Running time entry with specific start
// ──────────────────────────────────────────────────
export async function createRunningTimeEntryWithStartViaApi(
ctx: TestContext,
description: string,
start: string
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
{
data: {
member_id: ctx.memberId,
start,
description,
billable: false,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; start: string; end: null; description: string };
}
// ──────────────────────────────────────────────────
// Reports
// ──────────────────────────────────────────────────
export async function createReportViaApi(
ctx: TestContext,
data: {
name: string;
is_public?: boolean;
public_until?: string | null;
}
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/reports`,
{
data: {
name: data.name,
description: '',
is_public: data.is_public ?? true,
public_until: data.public_until ?? null,
properties: {
start: '2024-01-01T00:00:00Z',
end: '2030-12-31T23:59:59Z',
group: 'project',
sub_group: 'project',
history_group: 'day',
},
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as {
id: string;
name: string;
is_public: boolean;
public_until: string | null;
};
}

554
package-lock.json generated
View File

@@ -11,11 +11,6 @@
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6",
"@fullcalendar/core": "^6.1.18",
"@fullcalendar/daygrid": "^6.1.18",
"@fullcalendar/interaction": "^6.1.18",
"@fullcalendar/timegrid": "^6.1.18",
"@fullcalendar/vue3": "^6.1.18",
"@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/container-queries": "^0.1.1",
@@ -25,7 +20,7 @@
"@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^14.2.0",
"@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.0.0",
"@zodios/core": "^10.9.6",
"chroma-js": "3.1.2",
@@ -38,7 +33,7 @@
"parse-duration": "^2.0.1",
"pinia": "^3.0.0",
"radix-vue": "^1.9.6",
"reka-ui": "^2.8.0",
"reka-ui": "^2.8.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vue-echarts": "^8.0.0",
@@ -138,9 +133,9 @@
}
},
"node_modules/@apidevtools/swagger-parser/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1037,55 +1032,6 @@
}
}
},
"node_modules/@fullcalendar/core": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
"license": "MIT",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
"integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/timegrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
"integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==",
"license": "MIT",
"dependencies": {
"@fullcalendar/daygrid": "~6.1.20"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/vue3": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz",
"integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20",
"vue": "^3.0.11"
}
},
"node_modules/@heroicons/vue": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
@@ -1195,29 +1141,6 @@
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1283,22 +1206,22 @@
}
},
"node_modules/@microsoft/api-extractor": {
"version": "7.56.0",
"resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.56.0.tgz",
"integrity": "sha512-H0V69QG5jIb9Ayx35NVBv2lOgFSS3q+Eab2oyGEy0POL3ovYPST+rCNPbwYoczOZXNG8IKjWUmmAMxmDTsXlQA==",
"version": "7.57.6",
"resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.57.6.tgz",
"integrity": "sha512-0rFv/D8Grzw1Mjs2+8NGUR+o4h9LVm5zKRtMeWnpdB5IMJF4TeHCL1zR5LMCIudkOvyvjbhMG5Wjs0B5nqsrRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@microsoft/api-extractor-model": "7.32.2",
"@microsoft/api-extractor-model": "7.33.4",
"@microsoft/tsdoc": "~0.16.0",
"@microsoft/tsdoc-config": "~0.18.0",
"@rushstack/node-core-library": "5.19.1",
"@rushstack/rig-package": "0.6.0",
"@rushstack/terminal": "0.21.0",
"@rushstack/ts-command-line": "5.1.7",
"@microsoft/tsdoc-config": "~0.18.1",
"@rushstack/node-core-library": "5.20.3",
"@rushstack/rig-package": "0.7.2",
"@rushstack/terminal": "0.22.3",
"@rushstack/ts-command-line": "5.3.3",
"diff": "~8.0.2",
"lodash": "~4.17.15",
"minimatch": "10.0.3",
"lodash": "~4.17.23",
"minimatch": "10.2.1",
"resolve": "~1.22.1",
"semver": "~7.5.4",
"source-map": "~0.6.1",
@@ -1309,15 +1232,38 @@
}
},
"node_modules/@microsoft/api-extractor-model": {
"version": "7.32.2",
"resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.32.2.tgz",
"integrity": "sha512-Ussc25rAalc+4JJs9HNQE7TuO9y6jpYQX9nWD1DhqUzYPBr3Lr7O9intf+ZY8kD5HnIqeIRJX7ccCT0QyBy2Ww==",
"version": "7.33.4",
"resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.33.4.tgz",
"integrity": "sha512-u1LTaNTikZAQ9uK6KG1Ms7nvNedsnODnspq/gH2dcyETWvH4hVNGNDvRAEutH66kAmxA4/necElqGNs1FggC8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "~0.16.0",
"@microsoft/tsdoc-config": "~0.18.0",
"@rushstack/node-core-library": "5.19.1"
"@microsoft/tsdoc-config": "~0.18.1",
"@rushstack/node-core-library": "5.20.3"
}
},
"node_modules/@microsoft/api-extractor/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@microsoft/api-extractor/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@microsoft/api-extractor/node_modules/lru-cache": {
@@ -1334,13 +1280,13 @@
}
},
"node_modules/@microsoft/api-extractor/node_modules/minimatch": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz",
"integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==",
"dev": true,
"license": "ISC",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "20 || >=22"
@@ -1394,29 +1340,29 @@
"license": "MIT"
},
"node_modules/@microsoft/tsdoc-config": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.0.tgz",
"integrity": "sha512-8N/vClYyfOH+l4fLkkr9+myAoR6M7akc8ntBJ4DJdWH2b09uVfr71+LTMpNyG19fNqWDg8KEDZhx5wxuqHyGjw==",
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.1.tgz",
"integrity": "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "0.16.0",
"ajv": "~8.12.0",
"ajv": "~8.18.0",
"jju": "~1.4.0",
"resolve": "~1.22.2"
}
},
"node_modules/@microsoft/tsdoc-config/node_modules/ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
@@ -1536,9 +1482,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -1549,9 +1495,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -1562,9 +1508,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -1575,9 +1521,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -1588,9 +1534,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -1601,9 +1547,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -1614,9 +1560,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -1627,9 +1573,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -1640,9 +1586,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -1653,9 +1599,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -1666,9 +1612,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@@ -1679,9 +1625,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@@ -1692,9 +1638,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
@@ -1705,9 +1651,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -1718,9 +1664,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -1731,9 +1677,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -1744,9 +1690,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -1757,9 +1703,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -1770,9 +1716,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -1783,9 +1729,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
@@ -1796,9 +1742,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@@ -1809,9 +1755,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -1822,9 +1768,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -1835,9 +1781,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@@ -1848,9 +1794,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -1867,13 +1813,13 @@
"license": "MIT"
},
"node_modules/@rushstack/node-core-library": {
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.19.1.tgz",
"integrity": "sha512-ESpb2Tajlatgbmzzukg6zyAhH+sICqJR2CNXNhXcEbz6UGCQfrKCtkxOpJTftWc8RGouroHG0Nud1SJAszvpmA==",
"version": "5.20.3",
"resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.20.3.tgz",
"integrity": "sha512-95JgEPq2k7tHxhF9/OJnnyHDXfC9cLhhta0An/6MlkDsX2A6dTzDrTUG18vx4vjc280V0fi0xDH9iQczpSuWsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "~8.13.0",
"ajv": "~8.18.0",
"ajv-draft-04": "~1.0.0",
"ajv-formats": "~3.0.1",
"fs-extra": "~11.3.0",
@@ -1892,16 +1838,16 @@
}
},
"node_modules/@rushstack/node-core-library/node_modules/ajv": {
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz",
"integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.4.1"
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
@@ -1982,9 +1928,9 @@
"license": "ISC"
},
"node_modules/@rushstack/problem-matcher": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@rushstack/problem-matcher/-/problem-matcher-0.1.1.tgz",
"integrity": "sha512-Fm5XtS7+G8HLcJHCWpES5VmeMyjAKaWeyZU5qPzZC+22mPlJzAsOxymHiWIfuirtPckX3aptWws+K2d0BzniJA==",
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@rushstack/problem-matcher/-/problem-matcher-0.2.1.tgz",
"integrity": "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -1997,9 +1943,9 @@
}
},
"node_modules/@rushstack/rig-package": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.6.0.tgz",
"integrity": "sha512-ZQmfzsLE2+Y91GF15c65L/slMRVhF6Hycq04D4TwtdGaUAbIXXg9c5pKA5KFU7M4QMaihoobp9JJYpYcaY3zOw==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.7.2.tgz",
"integrity": "sha512-9XbFWuqMYcHUso4mnETfhGVUSaADBRj6HUAAEYk50nMPn8WRICmBuCphycQGNB3duIR6EEZX3Xj3SYc2XiP+9A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2008,14 +1954,14 @@
}
},
"node_modules/@rushstack/terminal": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.21.0.tgz",
"integrity": "sha512-cLaI4HwCNYmknM5ns4G+drqdEB6q3dCPV423+d3TZeBusYSSm09+nR7CnhzJMjJqeRcdMAaLnrA4M/3xDz4R3w==",
"version": "0.22.3",
"resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.22.3.tgz",
"integrity": "sha512-gHC9pIMrUPzAbBiI4VZMU7Q+rsCzb8hJl36lFIulIzoceKotyKL3Rd76AZ2CryCTKEg+0bnTj406HE5YY5OQvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rushstack/node-core-library": "5.19.1",
"@rushstack/problem-matcher": "0.1.1",
"@rushstack/node-core-library": "5.20.3",
"@rushstack/problem-matcher": "0.2.1",
"supports-color": "~8.1.1"
},
"peerDependencies": {
@@ -2044,13 +1990,13 @@
}
},
"node_modules/@rushstack/ts-command-line": {
"version": "5.1.7",
"resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.1.7.tgz",
"integrity": "sha512-Ugwl6flarZcL2nqH5IXFYk3UR3mBVDsVFlCQW/Oaqidvdb/5Ota6b/Z3JXWIdqV3rOR2/JrYoAHanWF5rgenXA==",
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.3.3.tgz",
"integrity": "sha512-c+ltdcvC7ym+10lhwR/vWiOhsrm/bP3By2VsFcs5qTKv+6tTmxgbVrtJ5NdNjANiV5TcmOZgUN+5KYQ4llsvEw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rushstack/terminal": "0.21.0",
"@rushstack/terminal": "0.22.3",
"@types/argparse": "1.0.38",
"argparse": "~1.0.9",
"string-argv": "~0.3.1"
@@ -2661,12 +2607,12 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -2996,14 +2942,14 @@
}
},
"node_modules/@vueuse/core": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.0.tgz",
"integrity": "sha512-tpjzVl7KCQNVd/qcaCE9XbejL38V6KJAEq/tVXj7mDPtl6JtzmUdnXelSS+ULRkkrDgzYVK7EerQJvd2jR794Q==",
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.2.0",
"@vueuse/shared": "14.2.0"
"@vueuse/metadata": "14.2.1",
"@vueuse/shared": "14.2.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
@@ -3012,6 +2958,18 @@
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/core/node_modules/@vueuse/shared": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/integrations": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.2.0.tgz",
@@ -3078,7 +3036,24 @@
}
}
},
"node_modules/@vueuse/metadata": {
"node_modules/@vueuse/integrations/node_modules/@vueuse/core": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.0.tgz",
"integrity": "sha512-tpjzVl7KCQNVd/qcaCE9XbejL38V6KJAEq/tVXj7mDPtl6JtzmUdnXelSS+ULRkkrDgzYVK7EerQJvd2jR794Q==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.2.0",
"@vueuse/shared": "14.2.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/integrations/node_modules/@vueuse/metadata": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.0.tgz",
"integrity": "sha512-i3axTGjU8b13FtyR4Keeama+43iD+BwX9C2TmzBVKqjSHArF03hjkp2SBZ1m72Jk2UtrX0aYCugBq2R1fhkuAQ==",
@@ -3087,6 +3062,15 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.0.tgz",
@@ -3131,9 +3115,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -3165,9 +3149,9 @@
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3297,13 +3281,13 @@
}
},
"node_modules/axios": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -5071,9 +5055,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -5846,16 +5830,6 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5910,9 +5884,9 @@
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -6118,9 +6092,9 @@
}
},
"node_modules/reka-ui": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.8.0.tgz",
"integrity": "sha512-N4JOyIrmDE7w2i06WytqcV2QICubtS2PsK5Uo8FIMAgmO13KhUAgAByP26cXjjm2oF/w7rTyRs8YaqtvaBT+SA==",
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.8.2.tgz",
"integrity": "sha512-8lTKcJhmG+D3UyJxhBnNnW/720sLzm0pbA9AC1MWazmJ5YchJAyTSl+O00xP/kxBmEN0fw5JqWVHguiFmsGjzA==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.13",
@@ -6134,6 +6108,10 @@
"defu": "^6.1.4",
"ohash": "^2.0.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/zernonia"
},
"peerDependencies": {
"vue": ">= 3.2.0"
}
@@ -6200,9 +6178,9 @@
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -6215,31 +6193,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
@@ -7149,13 +7127,13 @@
}
},
"node_modules/vite-plugin-dts/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -7434,7 +7412,7 @@
},
"resources/js/packages/ui": {
"name": "@solidtime/ui",
"version": "0.0.15",
"version": "0.0.16",
"license": "AGPL-3.0",
"devDependencies": {
"vite-plugin-dts": "^4.0.3"
@@ -7442,12 +7420,18 @@
"peerDependencies": {
"@floating-ui/vue": "^1.1.4",
"@heroicons/vue": "^2.1.5",
"@internationalized/date": "^3.0.0",
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
"@vueuse/core": "^12.5.0 || ^14.0.0",
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
"chroma-js": "^3.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"focus-trap": "^7.0.0 || ^8.0.0",
"lucide-vue-next": ">=0.453.0",
"parse-duration": "^2.0.1",
"radix-vue": "^1.9.0",
"reka-ui": "^2.2.0",
"tailwind-merge": "^2.5.2",
"tailwindcss": "^3.1.0",

View File

@@ -1,4 +1,5 @@
{
"name": "solidtime",
"private": true,
"type": "module",
"workspaces": [
@@ -45,11 +46,6 @@
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6",
"@fullcalendar/core": "^6.1.18",
"@fullcalendar/daygrid": "^6.1.18",
"@fullcalendar/interaction": "^6.1.18",
"@fullcalendar/timegrid": "^6.1.18",
"@fullcalendar/vue3": "^6.1.18",
"@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/container-queries": "^0.1.1",
@@ -59,7 +55,7 @@
"@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^14.2.0",
"@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.0.0",
"@zodios/core": "^10.9.6",
"chroma-js": "3.1.2",
@@ -72,7 +68,7 @@
"parse-duration": "^2.0.1",
"pinia": "^3.0.0",
"radix-vue": "^1.9.6",
"reka-ui": "^2.8.0",
"reka-ui": "^2.8.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vue-echarts": "^8.0.0",

View File

@@ -9,6 +9,8 @@ import { useTagsStore } from '@/utils/useTags';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { canCreateProjects } from '@/utils/permissions';
import type {
CreateClientBody,
@@ -37,6 +39,8 @@ import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const {
isOpen,
searchTerm,
@@ -162,6 +166,7 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
:create-client="createClient"
:clients="activeClients"
:currency="getOrganizationCurrencyString()"
:organization-billable-rate="organization?.billable_rate ?? null"
:enable-estimated-time="isAllowedToPerformPremiumAction()" />
<!-- Client Create Modal -->
@@ -192,7 +197,8 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
:clients="activeClients"
:currency="getOrganizationCurrencyString()"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:can-create-project="canCreateProjects()" />
:can-create-project="canCreateProjects()"
:organization-billable-rate="organization?.billable_rate ?? null" />
<!-- Project Selector Dialog for Active Timer -->
<DialogModal :show="showProjectSelector" closeable @close="showProjectSelector = false">
@@ -210,6 +216,7 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
:can-create-project="canCreateProjects()"
:currency="getOrganizationCurrencyString()"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:organization-billable-rate="organization?.billable_rate ?? null"
class="w-full" />
</template>
<template #footer>
@@ -234,6 +241,7 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
:can-create-project="canCreateProjects()"
:currency="getOrganizationCurrencyString()"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:organization-billable-rate="organization?.billable_rate ?? null"
class="w-full" />
</template>
<template #footer>

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
const emit = defineEmits<{
delete: [];

View File

@@ -2,11 +2,24 @@
import type { Client } from '@/packages/api/src';
import { computed, ref } from 'vue';
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';
import {
PencilSquareIcon,
ArchiveBoxIcon as ArchiveBoxIconSolid,
TrashIcon,
} from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients';
import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import TableRow from '@/Components/TableRow.vue';
import ClientEditModal from '@/Components/Common/Client/ClientEditModal.vue';
import { canUpdateClients, canDeleteClients } from '@/utils/permissions';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/packages/ui/src';
const { projects } = useProjectsQuery();
@@ -33,38 +46,63 @@ const showEditModal = ref(false);
</script>
<template>
<TableRow>
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
<div
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ client.name }}
</span>
</div>
<div
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="client.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ClientMoreOptionsDropdown
:client="client"
@edit="showEditModal = true"
@archive="archiveClient"
@delete="deleteClient"></ClientMoreOptionsDropdown>
</div>
</TableRow>
<ContextMenu>
<ContextMenuTrigger as-child>
<TableRow>
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
<div
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ client.name }}
</span>
</div>
<div
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="client.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ClientMoreOptionsDropdown
:client="client"
@edit="showEditModal = true"
@archive="archiveClient"
@delete="deleteClient"></ClientMoreOptionsDropdown>
</div>
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem
v-if="canUpdateClients()"
class="space-x-3"
@select="showEditModal = true">
<PencilSquareIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuItem v-if="canUpdateClients()" class="space-x-3" @select="archiveClient()">
<ArchiveBoxIconSolid class="w-4 h-4 text-icon-default" />
<span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>
</ContextMenuItem>
<ContextMenuSeparator v-if="canDeleteClients()" />
<ContextMenuItem
v-if="canDeleteClients()"
class="space-x-3 text-destructive"
@select="deleteClient()">
<TrashIcon class="w-4 h-4 text-icon-default" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</template>
<style scoped></style>

View File

@@ -5,7 +5,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
const emit = defineEmits<{
delete: [];

View File

@@ -1,11 +1,5 @@
<script setup lang="ts">
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import type { BillableKey } from '@/types/projects';
const model = defineModel<BillableKey>({

View File

@@ -7,13 +7,7 @@ import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { type MemberBillableKey, useMembersStore } from '@/utils/useMembers';
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import {
Tooltip,
TooltipContent,

View File

@@ -17,7 +17,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
const emit = defineEmits<{
delete: [];

View File

@@ -1,11 +1,5 @@
<script setup lang="ts">
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import type { Role } from '@/types/jetstream';
import { usePage } from '@inertiajs/vue3';

View File

@@ -2,12 +2,24 @@
import type { Member, Organization } from '@/packages/api/src';
import { api } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
import {
PencilSquareIcon,
TrashIcon,
ArrowDownOnSquareStackIcon,
UserCircleIcon as UserCircleIconSolid,
} from '@heroicons/vue/20/solid';
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
import TableRow from '@/Components/TableRow.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { canInvitePlaceholderMembers } from '@/utils/permissions';
import {
canInvitePlaceholderMembers,
canUpdateMembers,
canDeleteMembers,
canMergeMembers,
canMakeMembersPlaceholders,
} from '@/utils/permissions';
import { computed, type ComputedRef, inject, ref } from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
@@ -15,6 +27,13 @@ import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePla
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import { formatCents } from '../../../packages/ui/src/utils/money';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/packages/ui/src';
const props = defineProps<{
member: Member;
@@ -55,73 +74,112 @@ const userHasValidMailAddress = computed(() => {
</script>
<template>
<TableRow>
<div
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ member.name }}
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ member.email }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ capitalizeFirstLetter(member.role) }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{
member.billable_rate
? formatCents(
member.billable_rate,
organization?.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="member.is_placeholder === false">
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
<template v-else>
<UserCircleIcon class="w-4 text-icon-default"></UserCircleIcon>
<span>Inactive</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<SecondaryButton
v-if="
member.is_placeholder === true &&
canInvitePlaceholderMembers() &&
userHasValidMailAddress
"
size="small"
@click="invitePlaceholder(member.id)"
>Invite
</SecondaryButton>
<MemberMoreOptionsDropdown
:member="member"
@edit="showEditMemberModal = true"
@delete="removeMember"
@merge="showMergeMemberModal = true"
@make-placeholder="
showMakeMemberPlaceholderModal = true
"></MemberMoreOptionsDropdown>
</div>
<MemberEditModal v-model:show="showEditMemberModal" :member="member"></MemberEditModal>
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
<MemberMakePlaceholderModal
v-model:show="showMakeMemberPlaceholderModal"
:member="member"></MemberMakePlaceholderModal>
<MemberDeleteModal
v-model:show="showDeleteMemberModal"
:member="member"></MemberDeleteModal>
</TableRow>
<ContextMenu>
<ContextMenuTrigger as-child>
<TableRow>
<div
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ member.name }}
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ member.email }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ capitalizeFirstLetter(member.role) }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{
member.billable_rate
? formatCents(
member.billable_rate,
organization?.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="member.is_placeholder === false">
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
<template v-else>
<UserCircleIcon class="w-4 text-icon-default"></UserCircleIcon>
<span>Inactive</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<SecondaryButton
v-if="
member.is_placeholder === true &&
canInvitePlaceholderMembers() &&
userHasValidMailAddress
"
size="small"
@click="invitePlaceholder(member.id)"
>Invite
</SecondaryButton>
<MemberMoreOptionsDropdown
:member="member"
@edit="showEditMemberModal = true"
@delete="removeMember"
@merge="showMergeMemberModal = true"
@make-placeholder="
showMakeMemberPlaceholderModal = true
"></MemberMoreOptionsDropdown>
</div>
<MemberEditModal
v-model:show="showEditMemberModal"
:member="member"></MemberEditModal>
<MemberMergeModal
v-model:show="showMergeMemberModal"
:member="member"></MemberMergeModal>
<MemberMakePlaceholderModal
v-model:show="showMakeMemberPlaceholderModal"
:member="member"></MemberMakePlaceholderModal>
<MemberDeleteModal
v-model:show="showDeleteMemberModal"
:member="member"></MemberDeleteModal>
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem
v-if="canUpdateMembers()"
class="space-x-3"
@select="showEditMemberModal = true">
<PencilSquareIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuItem
v-if="member.role === 'placeholder' && canMergeMembers()"
class="space-x-3"
@select="showMergeMemberModal = true">
<ArrowDownOnSquareStackIcon class="w-4 h-4 text-icon-default" />
<span>Merge</span>
</ContextMenuItem>
<ContextMenuItem
v-if="member.role !== 'placeholder' && canMakeMembersPlaceholders()"
class="space-x-3"
@select="showMakeMemberPlaceholderModal = true">
<UserCircleIconSolid class="w-4 h-4 text-icon-default" />
<span>Deactivate</span>
</ContextMenuItem>
<ContextMenuSeparator v-if="canDeleteMembers()" />
<ContextMenuItem
v-if="canDeleteMembers()"
class="space-x-3 text-destructive"
@select="showDeleteMemberModal = true">
<TrashIcon class="w-4 h-4 text-icon-default" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</template>
<style scoped></style>

View File

@@ -1,11 +1,7 @@
<script setup lang="ts">
import { XMarkIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { Component } from 'vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/packages/ui/src';
defineProps<{
icon: Component;

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { UserGroupIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/Components/ui/dropdown-menu';
import { DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/packages/ui/src';
import BaseFilterBadge from './BaseFilterBadge.vue';
import type { Client } from '@/packages/api/src';
import { NO_CLIENT_ID } from './constants';

View File

@@ -20,7 +20,10 @@ import { useClientsQuery } from '@/utils/useClientsQuery';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const searchValue = ref('');
const searchInput = ref<HTMLElement | null>(null);
const model = defineModel<string | null>({
@@ -156,6 +159,7 @@ function updateValue(project: Project) {
:create-client="handleCreateClient"
:clients="activeClients"
:currency="getOrganizationCurrencyString()"
:organization-billable-rate="organization?.billable_rate ?? null"
:enable-estimated-time="isAllowedToPerformPremiumAction()" />
</template>

View File

@@ -20,9 +20,12 @@ import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableR
import { getOrganizationCurrencyString } from '@/utils/money';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
const { updateProject } = useProjectsStore();
const { clients } = useClientsQuery();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const show = defineModel('show', { default: false });
const saving = ref(false);
const showBillableRateModal = ref(false);
@@ -117,6 +120,7 @@ async function submitBillableRate() {
v-model:is-billable="project.is_billable"
v-model:billable-rate="project.billable_rate"
:currency="getOrganizationCurrencyString()"
:organization-billable-rate="organization?.billable_rate ?? null"
@submit="submit"></ProjectEditBillableSection>
<EstimatedTimeSection
v-if="isAllowedToPerformPremiumAction()"

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
const emit = defineEmits<{
delete: [];

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { CircleStackIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuItem } from '@/Components/ui/dropdown-menu';
import { DropdownMenuItem } from '@/packages/ui/src';
import BaseFilterBadge from './BaseFilterBadge.vue';
type StatusValue = 'active' | 'archived' | 'all';

View File

@@ -22,6 +22,8 @@ import { useClientsStore } from '@/utils/useClients';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
import {
useVueTable,
getCoreRowModel,
@@ -29,6 +31,8 @@ import {
type SortingState,
} from '@tanstack/vue-table';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const props = defineProps<{
projects: Project[];
showBillableRate: boolean;
@@ -155,6 +159,7 @@ const gridTemplate = computed(() => {
:create-project
:create-client
:currency="getOrganizationCurrencyString()"
:organization-billable-rate="organization?.billable_rate ?? null"
:clients="clients"
:enable-estimated-time="isAllowedToPerformPremiumAction()"></ProjectCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto">

View File

@@ -3,6 +3,11 @@ import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreO
import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue';
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';
import {
PencilSquareIcon,
ArchiveBoxIcon as ArchiveBoxIconSolid,
TrashIcon,
} from '@heroicons/vue/20/solid';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsStore } from '@/utils/useProjects';
@@ -14,7 +19,15 @@ import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canUpdateProjects, canDeleteProjects } from '@/utils/permissions';
import type { Organization } from '@/packages/api/src';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/packages/ui/src';
const { clients } = useClientsQuery();
const { tasks } = useTasksQuery();
@@ -69,71 +82,99 @@ const showEditProjectModal = ref(false);
<ProjectEditModal
v-model:show="showEditProjectModal"
:original-project="project"></ProjectEditModal>
<TableRow :href="route('projects.show', { project: project.id })">
<div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
:style="{
backgroundColor: project.color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
}"
class="w-3 h-3 rounded-full"></div>
<span class="overflow-ellipsis overflow-hidden">
{{ project.name }}
</span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
{{ client?.name }}
</div>
<div v-else>No client</div>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div v-if="project.spent_time">
{{
formatHumanReadableDuration(
project.spent_time,
organization?.interval_format,
organization?.number_format
)
}}
</div>
<div v-else>--</div>
</div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress
v-else-if="project.estimated_time"
:estimated="project.estimated_time"
:current="project.spent_time"></EstimatedTimeProgress>
<span v-else> -- </span>
</div>
<div
v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ billableRateInfo }}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ProjectMoreOptionsDropdown
:project="project"
@edit="showEditProjectModal = true"
@archive="archiveProject"
@delete="deleteProject"></ProjectMoreOptionsDropdown>
</div>
</TableRow>
<ContextMenu>
<ContextMenuTrigger as-child>
<TableRow :href="route('projects.show', { project: project.id })">
<div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
:style="{
backgroundColor: project.color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
}"
class="w-3 h-3 rounded-full"></div>
<span class="overflow-ellipsis overflow-hidden">
{{ project.name }}
</span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
{{ client?.name }}
</div>
<div v-else>No client</div>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div v-if="project.spent_time">
{{
formatHumanReadableDuration(
project.spent_time,
organization?.interval_format,
organization?.number_format
)
}}
</div>
<div v-else>--</div>
</div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress
v-else-if="project.estimated_time"
:estimated="project.estimated_time"
:current="project.spent_time"></EstimatedTimeProgress>
<span v-else> -- </span>
</div>
<div
v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ billableRateInfo }}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ProjectMoreOptionsDropdown
:project="project"
@edit="showEditProjectModal = true"
@archive="archiveProject"
@delete="deleteProject"></ProjectMoreOptionsDropdown>
</div>
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem
v-if="canUpdateProjects()"
class="space-x-3"
@select="showEditProjectModal = true">
<PencilSquareIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuItem
v-if="canUpdateProjects()"
class="space-x-3"
@select="archiveProject()">
<ArchiveBoxIconSolid class="w-4 h-4 text-icon-default" />
<span>{{ project.is_archived ? 'Unarchive' : 'Archive' }}</span>
</ContextMenuItem>
<ContextMenuSeparator v-if="canDeleteProjects()" />
<ContextMenuItem
v-if="canDeleteProjects()"
class="space-x-3 text-destructive"
@select="deleteProject()">
<TrashIcon class="w-4 h-4 text-icon-default" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</template>
<style scoped></style>

View File

@@ -12,7 +12,7 @@ import {
DropdownMenuSubContent,
DropdownMenuCheckboxItem,
DropdownMenuSeparator,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
import { Button } from '@/packages/ui/src';
import type { Client } from '@/packages/api/src';
import { NO_CLIENT_ID } from './constants';

View File

@@ -8,7 +8,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
const emit = defineEmits<{
delete: [];

View File

@@ -111,7 +111,7 @@ async function submit() {
<FieldLabel for="public_until">Expires at</FieldLabel>
<div class="text-text-tertiary font-medium">(optional)</div>
</div>
<DatePicker v-model="report.public_until"></DatePicker>
<DatePicker v-model="report.public_until" clearable></DatePicker>
</Field>
</div>
</Field>

View File

@@ -125,7 +125,7 @@ async function submit() {
</Field>
<Field v-if="report.is_public" orientation="horizontal">
<FieldLabel for="public_until">Expires at</FieldLabel>
<DatePicker v-model="localPublicUntil"></DatePicker>
<DatePicker v-model="localPublicUntil" clearable></DatePicker>
</Field>
</div>
</Field>

View File

@@ -6,7 +6,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
import { canDeleteReport, canUpdateReport } from '@/utils/permissions';
const emit = defineEmits<{

View File

@@ -13,7 +13,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import type { AggregatedTimeEntries, Organization } from '@/packages/api/src';
import { useCssVariable } from '@/utils/useCssVariable';
import { useCssVariable } from '@/packages/ui/src';
use([CanvasRenderer, BarChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);

View File

@@ -6,7 +6,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
import type { ExportFormat } from '@/types/reporting';
import { ref } from 'vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';

View File

@@ -8,13 +8,7 @@ import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultisel
import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';

View File

@@ -1,11 +1,5 @@
<script setup lang="ts">
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import { type Component, computed } from 'vue';
const model = defineModel<string | null>({ default: null });

View File

@@ -28,7 +28,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import { canCreateReports } from '@/utils/permissions';

View File

@@ -11,7 +11,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/utils/useCssVariable';
import { useCssVariable } from '@/packages/ui/src';
import type { Organization } from '@/packages/api/src';
use([CanvasRenderer, PieChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);

View File

@@ -2,13 +2,7 @@
import { Switch } from '@/Components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
import { Button } from '@/packages/ui/src';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import { Field, FieldLabel } from '@/packages/ui/src/field';
import {
NumberField,
@@ -16,7 +10,7 @@ import {
NumberFieldContent,
NumberFieldIncrement,
NumberFieldDecrement,
} from '@/Components/ui/number-field';
} from '@/packages/ui/src';
import { ArrowsUpDownIcon } from '@heroicons/vue/20/solid';
import { computed, ref, watch } from 'vue';
import { twMerge } from 'tailwind-merge';

View File

@@ -2,8 +2,7 @@
import { router } from '@inertiajs/vue3';
import { canViewReport } from '@/utils/permissions';
import { computed } from 'vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import { TabBar, TabBarItem } from '@/packages/ui/src';
const props = defineProps<{
active: 'reporting' | 'detailed' | 'shared';

View File

@@ -8,7 +8,7 @@ defineProps<{
<template>
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-semibold text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-2xl text-text-primary pt-1 font-semibold">
<dd class="text-xl text-text-primary pt-1 font-semibold">
{{ value ?? '--' }}
</dd>
</div>

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
const emit = defineEmits<{
edit: [];

View File

@@ -6,6 +6,14 @@ import TagEditModal from '@/Components/Common/Tag/TagEditModal.vue';
import TableRow from '@/Components/TableRow.vue';
import { canDeleteTags, canUpdateTags } from '@/utils/permissions';
import { ref } from 'vue';
import { PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/packages/ui/src';
const props = defineProps<{
tag: Tag;
@@ -19,23 +27,44 @@ function deleteTag() {
</script>
<template>
<TableRow>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ tag.name }}
</span>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<TagMoreOptionsDropdown
v-if="canDeleteTags() || canUpdateTags()"
:tag="tag"
@edit="showTagEditModal = true"
@delete="deleteTag"></TagMoreOptionsDropdown>
</div>
<TagEditModal v-model:show="showTagEditModal" :tag="tag"></TagEditModal>
</TableRow>
<ContextMenu>
<ContextMenuTrigger as-child>
<TableRow>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ tag.name }}
</span>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<TagMoreOptionsDropdown
v-if="canDeleteTags() || canUpdateTags()"
:tag="tag"
@edit="showTagEditModal = true"
@delete="deleteTag"></TagMoreOptionsDropdown>
</div>
<TagEditModal v-model:show="showTagEditModal" :tag="tag"></TagEditModal>
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem
v-if="canUpdateTags()"
class="space-x-3"
@select="showTagEditModal = true">
<PencilSquareIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuSeparator v-if="canDeleteTags()" />
<ContextMenuItem
v-if="canDeleteTags()"
class="space-x-3 text-destructive"
@select="deleteTag()">
<TrashIcon class="w-4 h-4 text-icon-default" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</template>
<style scoped></style>

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
const emit = defineEmits<{
delete: [];

View File

@@ -44,7 +44,7 @@ const isRunningInDifferentOrganization = computed(() => {
</div>
<div>
<div class="text-text-secondary font-medium text-xs">Current Timer</div>
<div class="text-text-primary font-medium text-lg">
<div class="text-text-primary font-medium text-base">
{{ currentTime }}
</div>
</div>

View File

@@ -20,7 +20,7 @@ import {
getDayJsInstance,
} from '@/packages/ui/src/utils/time';
import chroma from 'chroma-js';
import { useCssVariable } from '@/utils/useCssVariable';
import { useCssVariable } from '@/packages/ui/src';
import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api, type Organization } from '@/packages/api/src';

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import VChart from 'vue-echarts';
import { computed } from 'vue';
import { useCssVariable } from '@/utils/useCssVariable';
import { useCssVariable } from '@/packages/ui/src';
const props = defineProps<{
history: number[];

View File

@@ -11,7 +11,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/utils/useCssVariable';
import { useCssVariable } from '@/packages/ui/src';
import type { Organization } from '@/packages/api/src';
use([CanvasRenderer, PieChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);

View File

@@ -19,7 +19,7 @@ import ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTabl
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import { getWeekStart } from '@/packages/ui/src/utils/settings';
import { useCssVariable } from '@/utils/useCssVariable';
import { useCssVariable } from '@/packages/ui/src';
import { getOrganizationCurrencyString } from '@/utils/money';
import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';

View File

@@ -17,7 +17,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
const page = usePage<{
jetstream: {

View File

@@ -11,6 +11,7 @@ import duration from 'dayjs/plugin/duration';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { switchOrganization } from '@/utils/useOrganization';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
@@ -47,6 +48,8 @@ dayjs.extend(duration);
dayjs.extend(utc);
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry, isActive, now } = storeToRefs(currentTimeEntryStore);
const { startLiveTimer, stopLiveTimer, setActiveState } = currentTimeEntryStore;
@@ -152,12 +155,13 @@ const { tags } = useTagsQuery();
:create-time-entry="createTimeEntry"
:currency="getOrganizationCurrencyString()"
:can-create-project="canCreateProjects()"
:organization-billable-rate="organization?.billable_rate ?? null"
:projects
:tasks
:tags
:clients></TimeEntryCreateModal>
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
<div class="relative pt-1">
<div class="relative pt-1.5">
<TimeTrackerRunningInDifferentOrganizationOverlay
v-if="isRunningInDifferentOrganization"
@switch-organization="
@@ -173,6 +177,7 @@ const { tags } = useTagsQuery();
:create-project
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:can-create-project="canCreateProjects()"
:organization-billable-rate="organization?.billable_rate ?? null"
:create-client
:clients
:tags

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
import {
UserCircleIcon,
KeyIcon,

View File

@@ -1,71 +0,0 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
import { Button } from '@/packages/ui/src';
import { Calendar } from '@/Components/ui/calendar';
import { CalendarIcon, XIcon } from 'lucide-vue-next';
import { formatDate } from '@/packages/ui/src/utils/time';
import { parseDate } from '@internationalized/date';
import { computed, inject, type ComputedRef } from 'vue';
import { type Organization } from '@/packages/api/src';
const model = defineModel<string | null>();
const emit = defineEmits<{
blur: [];
}>();
defineProps<{
clearable?: boolean;
}>();
const handleChange = (date: string) => {
model.value = date;
};
const handleBlur = () => {
emit('blur');
};
const handleClear = (event: Event) => {
event.stopPropagation();
model.value = null;
};
const date = computed(() => {
return model.value ? parseDate(model.value) : undefined;
});
const organization = inject<ComputedRef<Organization>>('organization');
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button
variant="input"
:class="[
'w-full justify-start text-left font-normal',
!model && 'text-muted-foreground',
]">
<CalendarIcon class="mr-2 h-4 w-4" />
<span class="flex-1">
{{ model ? formatDate(model, organization?.date_format) : 'Pick a date' }}
</span>
<button
v-if="clearable && model"
class="ml-2 hover:bg-muted rounded p-1 transition-colors"
type="button"
@click="handleClear">
<XIcon class="h-4 w-4" />
</button>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar
mode="single"
:model-value="date"
:initial-focus="true"
@update:model-value="(date) => handleChange(date ? date.toString() : '')"
@blur="handleBlur" />
</PopoverContent>
</Popover>
</template>

View File

@@ -2,7 +2,7 @@
import AppLayout from '@/Layouts/AppLayout.vue';
import { useTimeEntriesCalendarQuery } from '@/utils/useTimeEntriesCalendarQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { computed, ref } from 'vue';
import { computed, ref, onMounted } from 'vue';
import { useQueryClient } from '@tanstack/vue-query';
import {
type Client,
@@ -11,6 +11,7 @@ import {
type Project,
} from '@/packages/api/src';
import { TimeEntryCalendar } from '@/packages/ui/src';
import type { ActivityPeriod } from '@/packages/ui/src/FullCalendar/activityTypes';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useTagsStore } from '@/utils/useTags';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
@@ -21,10 +22,34 @@ import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money';
import { canCreateProjects } from '@/utils/permissions';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const calendarStart = ref<Date | undefined>(undefined);
const calendarEnd = ref<Date | undefined>(undefined);
// Test-injectable activity periods (for E2E testing).
// These hooks are no-ops in production — they only take effect when test code
// explicitly sets window globals, so they are safe to ship.
const testActivityPeriods = ref<ActivityPeriod[]>([]);
onMounted(() => {
(window as unknown as Record<string, unknown>).__TEST_SET_ACTIVITY_PERIODS__ = (
data: ActivityPeriod[]
) => {
testActivityPeriods.value = data;
};
const windowData = (window as unknown as Record<string, unknown>).__TEST_ACTIVITY_PERIODS__;
if (Array.isArray(windowData)) {
setTimeout(() => {
testActivityPeriods.value = windowData;
}, 2000);
}
});
const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useTimeEntriesCalendarQuery(
calendarStart,
calendarEnd
@@ -81,13 +106,17 @@ function onDatesChange({ start, end }: { start: Date; end: Date }) {
function onRefresh() {
queryClient.invalidateQueries({
queryKey: ['timeEntries', 'calendar'],
queryKey: ['timeEntries'],
});
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
}
</script>
<template>
<AppLayout title="Calendar" data-testid="calendar_view" main-class="p-0">
<AppLayout
title="Calendar"
data-testid="calendar_view"
main-class="p-0 min-h-0 overflow-hidden">
<TimeEntryCalendar
:time-entries="currentTimeEntries"
:projects="projects"
@@ -98,12 +127,14 @@ function onRefresh() {
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:currency="getOrganizationCurrencyString()"
:can-create-project="canCreateProjects()"
:organization-billable-rate="organization?.billable_rate ?? null"
:create-time-entry="createTimeEntry"
:update-time-entry="updateTimeEntry"
:delete-time-entry="deleteTimeEntry"
:create-client="createClient"
:create-project="createProject"
:create-tag="createTag"
:activity-periods="testActivityPeriods"
@dates-change="onDatesChange"
@refresh="onRefresh" />
</AppLayout>

View File

@@ -10,8 +10,7 @@ import ClientTable from '@/Components/Common/Client/ClientTable.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateClients } from '@/utils/permissions';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import { TabBar, TabBarItem } from '@/packages/ui/src';
import { useStorage } from '@vueuse/core';
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';

View File

@@ -4,8 +4,7 @@ import AppLayout from '@/Layouts/AppLayout.vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { UserGroupIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import { TabBar, TabBarItem } from '@/packages/ui/src';
import { ref } from 'vue';
import MemberTable from '@/Components/Common/Member/MemberTable.vue';
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';

View File

@@ -1,13 +1,7 @@
<script setup lang="ts">
import FormSection from '@/Components/FormSection.vue';
import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import { usePreferredColorScheme } from '@vueuse/core';
import { themeSetting } from '@/utils/theme';

View File

@@ -21,8 +21,7 @@ import ProjectMemberTable from '@/Components/Common/ProjectMember/ProjectMemberT
import ProjectMemberCreateModal from '@/Components/Common/ProjectMember/ProjectMemberCreateModal.vue';
import { useProjectMembersQuery } from '@/utils/useProjectMembersQuery';
import { canCreateProjects, canCreateTasks, canViewProjectMembers } from '@/utils/permissions';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import { TabBar, TabBarItem } from '@/packages/ui/src';
import { useTasksQuery } from '@/utils/useTasksQuery';
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
import { Badge } from '@/packages/ui/src';

View File

@@ -131,6 +131,7 @@ const showBillableRate = computed(() => {
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:create-client
:currency="getOrganizationCurrencyString()"
:organization-billable-rate="organization?.billable_rate ?? null"
:clients="clients"
@submit="createProject"></ProjectCreateModal>
</MainContainer>

View File

@@ -18,7 +18,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
} from '@/packages/ui/src';
import { SecondaryButton } from '@/packages/ui/src';
import { computed, onMounted, ref, watch } from 'vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
@@ -66,6 +66,7 @@ import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportM
import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar.vue';
import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -89,6 +90,7 @@ const roundingType = ref<TimeEntryRoundingType>('nearest');
const roundingMinutes = ref<number>(15);
const { members } = useMembersQuery();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const pageLimit = 15;
// Watch rounding enabled state to trigger updates
@@ -353,6 +355,7 @@ async function downloadExport(format: ExportFormat) {
:tags="tags"
:currency="getOrganizationCurrencyString()"
:clients="clients"
:organization-billable-rate="organization?.billable_rate ?? null"
class="border-b border-default-background-separator"
:update-time-entries="
(args) =>
@@ -384,6 +387,7 @@ async function downloadExport(format: ExportFormat) {
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
:delete-time-entry="() => deleteTimeEntries([entry])"
:currency="getOrganizationCurrencyString()"
:organization-billable-rate="organization?.billable_rate ?? null"
:duplicate-time-entry="() => createTimeEntry(entry)"
:members="members"
show-date

View File

@@ -6,13 +6,7 @@ import { Field, FieldLabel } from '@/packages/ui/src/field';
import type { UpdateOrganizationBody } from '@/packages/api/src';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import type { DateFormat, TimeFormat, IntervalFormat } from '@/packages/ui/src/utils/time';
import type { CurrencyFormat } from '@/packages/ui/src/utils/money';

View File

@@ -26,6 +26,8 @@ import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassAct
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useTagsStore } from '@/utils/useTags';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
@@ -87,6 +89,8 @@ async function createClient(body: CreateClientBody): Promise<Client | undefined>
return await useClientsStore().createClient(body);
}
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const selectedTimeEntries = ref([] as TimeEntry[]);
async function clearSelectionAndState() {
@@ -115,6 +119,7 @@ function deleteSelected() {
:tags="tags"
:currency="getOrganizationCurrencyString()"
:clients="clients"
:organization-billable-rate="organization?.billable_rate ?? null"
class="border-t border-default-background-separator hidden sm:block"
:update-time-entries="
(args) =>
@@ -134,6 +139,7 @@ function deleteSelected() {
:create-project
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:can-create-project="canCreateProjects()"
:organization-billable-rate="organization?.billable_rate ?? null"
:clients
:create-client
:update-time-entry
@@ -157,7 +163,7 @@ function deleteSelected() {
<div ref="loadMoreContainer">
<div
v-if="isFetchingNextPage"
class="flex justify-center items-center py-5 text-text-primary font-medium">
class="flex justify-center items-center py-5 text-sm text-text-primary font-medium">
<LoadingSpinner></LoadingSpinner>
<span> Loading more time entries... </span>
</div>

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/ui",
"version": "0.0.16",
"version": "0.0.17",
"description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js",
@@ -21,7 +21,7 @@
"default": "./dist/solidtime-ui-lib.umd.cjs"
}
},
"./style.css": "./dist/style.css",
"./style.css": "./dist/solidtime-ui-lib.css",
"./styles.css": "./styles.css",
"./tailwind.theme.js": "./tailwind.theme.js"
},
@@ -48,17 +48,26 @@
"author": "solidtime",
"license": "AGPL-3.0",
"devDependencies": {
"vite-plugin-dts": "^4.0.3"
"@types/chroma-js": "^3.1.0",
"@zodios/core": "^10.9.6",
"vite-plugin-dts": "^4.0.3",
"zod": "^3.23.8"
},
"peerDependencies": {
"@floating-ui/vue": "^1.1.4",
"@heroicons/vue": "^2.1.5",
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
"@vueuse/core": "^12.5.0 || ^14.0.0",
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
"focus-trap": "^7.0.0 || ^8.0.0",
"chroma-js": "^3.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"lucide-vue-next": ">=0.453.0",
"@internationalized/date": "^3.0.0",
"parse-duration": "^2.0.1",
"radix-vue": "^1.9.0",
"reka-ui": "^2.2.0",
"tailwind-merge": "^2.5.2",
"tailwindcss": "^3.1.0",

View File

@@ -0,0 +1,283 @@
<script setup lang="ts">
import FullCalendarEventContent from './FullCalendarEventContent.vue';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '..';
import type { DayEvent, ActivityBox } from './calendarTypes';
import type { WindowActivityInPeriod } from './activityTypes';
defineProps<{
dayStr: string;
totalGridHeight: number;
hasActivityStatus: boolean;
// Events
dayEvents: DayEvent[];
getEventStyle: (dayEvent: DayEvent, dayStr: string) => Record<string, string>;
getEventOpacityClass: (dayEvent: DayEvent, dayStr: string) => string;
getEventDurationSeconds: (dayEvent: DayEvent, dayStr: string) => number;
// Drag state
isDragging: boolean;
dragEventId: string | null;
dragPreview: Record<string, string> | undefined;
// Resize state
resizeEventId: string | null;
resizeCrossDayPreview: Record<string, string> | undefined;
// Now indicator
showNowIndicator: boolean;
nowIndicatorTop: number;
// Activity boxes
activityBoxes: ActivityBox[];
getActivityBoxLabel: (box: ActivityBox) => string;
getActivityBoxActivities: (box: ActivityBox) => WindowActivityInPeriod[];
getActivityPercentage: (count: number, total: number) => string;
getActivityText: (activity: WindowActivityInPeriod) => string;
// Selection
showSelection: boolean;
isSelectionStart: boolean;
isSelectionIntermediate: boolean;
isSelectionEnd: boolean;
selectionTop: number;
selectionHeight: number;
selectionEndTop: number;
selectionEndHeight: number;
}>();
const emit = defineEmits<{
(e: 'event-pointerdown', event: PointerEvent, dayEvent: DayEvent): void;
(e: 'event-keydown-enter', dayEvent: DayEvent): void;
(
e: 'resizer-pointerdown',
event: PointerEvent,
dayEvent: DayEvent,
edge: 'start' | 'end'
): void;
}>();
</script>
<template>
<div
class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none"
:class="{
'has-activity-status': hasActivityStatus,
}"
:data-date="dayStr"
:style="{ height: totalGridHeight + 'px' }">
<div
class="absolute inset-y-0 left-0.5 right-0.5"
:class="{ 'fc-events-inset': hasActivityStatus }">
<div
v-for="dayEvent in dayEvents"
:key="dayEvent.event.id"
class="fc-event group pointer-events-auto rounded-sm text-xs cursor-pointer shadow-card overflow-hidden border border-border touch-none select-none hover:shadow-dropdown focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
:class="[
getEventOpacityClass(dayEvent, dayStr),
{
'running-entry rounded-b-none': dayEvent.event.isRunning,
'fc-event-dragging': isDragging && dragEventId === dayEvent.event.id,
'fc-event-resizing': resizeEventId === dayEvent.event.id,
'rounded-t-none': dayEvent.isClippedStart,
'rounded-b-none': dayEvent.isClippedEnd,
'fc-event-clipped-start': dayEvent.isClippedStart,
'fc-event-clipped-end': dayEvent.isClippedEnd,
},
]"
:data-event-id="dayEvent.event.id"
:style="getEventStyle(dayEvent, dayStr)"
tabindex="0"
:aria-label="dayEvent.event.title"
role="button"
@pointerdown="emit('event-pointerdown', $event, dayEvent)"
@keydown.enter.prevent="
!dayEvent.event.isRunning && emit('event-keydown-enter', dayEvent)
">
<div
v-if="!dayEvent.isClippedStart"
class="fc-event-resizer fc-event-resizer-start absolute z-[99] w-full h-3 left-0 top-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100"
@pointerdown.stop.prevent="
emit('resizer-pointerdown', $event, dayEvent, 'start')
"></div>
<div class="px-1 py-0.5 h-full overflow-hidden">
<FullCalendarEventContent
:title="dayEvent.event.title"
:project-name="dayEvent.event.project?.name"
:task-name="dayEvent.event.task?.name"
:client-name="dayEvent.event.client?.name"
:duration-seconds="getEventDurationSeconds(dayEvent, dayStr)" />
</div>
<div
v-if="!dayEvent.event.isRunning && !dayEvent.isClippedEnd"
class="fc-event-resizer fc-event-resizer-end absolute z-[99] w-full h-3 left-0 bottom-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100"
@pointerdown.stop.prevent="
emit('resizer-pointerdown', $event, dayEvent, 'end')
"></div>
</div>
</div>
<div
v-if="showNowIndicator"
class="fc-timegrid-now-indicator-line absolute left-0 right-0 border-t-2 border-red-500 z-50 pointer-events-none"
:style="{ top: nowIndicatorTop + 'px' }"></div>
<TooltipProvider :delay-duration="0">
<Tooltip v-for="(abox, ai) in activityBoxes" :key="'activity-' + ai">
<TooltipTrigger as-child>
<div
class="activity-status-box"
:class="abox.isIdle ? 'idle' : 'active'"
:style="{ top: abox.top + 'px', height: abox.height + 'px' }"></div>
</TooltipTrigger>
<TooltipContent side="right" :side-offset="8">
<template v-if="getActivityBoxActivities(abox).length === 0">
{{ getActivityBoxLabel(abox) }}
</template>
<div v-else class="max-w-[300px]">
<div class="font-semibold mb-2">{{ getActivityBoxLabel(abox) }}</div>
<div
v-for="(activity, actIdx) in getActivityBoxActivities(abox).slice(0, 5)"
:key="actIdx"
class="mt-1 text-[11px] opacity-90 flex items-center gap-1.5">
<img
v-if="activity.icon"
:src="activity.icon"
:alt="activity.appName"
class="w-4 h-4 rounded-sm shrink-0" />
<div
v-else
class="w-4 h-4 rounded-sm bg-white/10 flex items-center justify-center text-[8px] shrink-0">
{{ activity.appName.charAt(0).toUpperCase() }}
</div>
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
{{
getActivityPercentage(
activity.count,
getActivityBoxActivities(abox).reduce(
(sum, a) => sum + a.count,
0
)
)
}}%
{{ getActivityText(activity) }}
</span>
</div>
<div
v-if="getActivityBoxActivities(abox).length > 5"
class="mt-1 text-[11px] opacity-70 italic">
...and {{ getActivityBoxActivities(abox).length - 5 }} more
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div
v-if="showSelection && isSelectionStart"
class="absolute inset-x-0 pointer-events-none bg-accent border border-primary z-[2]"
:style="{
top: selectionTop + 'px',
height: selectionHeight + 'px',
}"></div>
<div
v-if="showSelection && isSelectionIntermediate"
class="absolute inset-x-0 pointer-events-none bg-accent border border-primary z-[2]"
:style="{
top: '0px',
height: totalGridHeight + 'px',
}"></div>
<div
v-if="showSelection && isSelectionEnd"
class="absolute inset-x-0 pointer-events-none bg-accent border border-primary z-[2]"
:style="{
top: selectionEndTop + 'px',
height: selectionEndHeight + 'px',
}"></div>
<div
v-if="isDragging && dragPreview"
class="fc-cross-day-preview pointer-events-none mx-px"
:style="dragPreview"></div>
<div
v-if="resizeCrossDayPreview"
class="fc-cross-day-preview pointer-events-none mx-px"
:style="resizeCrossDayPreview"></div>
</div>
</template>
<style scoped>
.fc-event-resizer::after {
content: '';
width: 24px;
height: 3px;
border-radius: 1.5px;
background: rgba(255, 255, 255, 0.6);
}
.fc-event-resizer:hover::after {
background: rgba(255, 255, 255, 0.9);
}
.fc-event-resizing,
.fc-event-resizing .fc-event-resizer {
cursor: row-resize !important;
}
.fc-event-resizing {
box-shadow: var(--theme-shadow-dropdown);
}
.fc-event-resizing .fc-event-resizer {
opacity: 1;
}
.fc-event-resizing .fc-event-resizer::after {
background: rgba(255, 255, 255, 0.9);
}
.running-entry .fc-event-resizer-end {
display: none;
}
.fc-timegrid-now-indicator-line::before {
content: '';
position: absolute;
top: -5px;
left: -4px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #ef4444;
}
.activity-status-box {
position: absolute;
width: 10px;
left: 0;
z-index: 10;
cursor: default;
pointer-events: auto;
}
.activity-status-box::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 5px;
transition: opacity 0.2s ease;
}
.activity-status-box.idle::before {
background-color: rgba(156, 163, 175, 0.1);
}
.activity-status-box.idle:hover::before {
background-color: rgba(156, 163, 175, 0.5);
}
.activity-status-box.active::before {
background-color: rgba(34, 197, 94, 0.3);
}
.activity-status-box.active:hover::before {
background-color: rgba(34, 197, 94, 1);
}
.fc-events-inset {
left: 8px;
}
</style>

View File

@@ -1,12 +1,6 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger, Button } from '..';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Field, FieldLabel } from '../field';
import { Settings } from 'lucide-vue-next';
import { ref, watch } from 'vue';

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { Button } from '..';
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';
import { Tabs, TabsList } from '../tabs';
import TabBarItem from '../TabBar/TabBarItem.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
import type { CalendarSettings } from './calendarSettings';
defineProps<{
viewTitle: string;
activeView: string;
settings: CalendarSettings;
}>();
const emit = defineEmits<{
prev: [];
next: [];
today: [];
'change-view': [view: string];
'update:settings': [value: CalendarSettings];
}>();
</script>
<template>
<div class="flex items-center justify-between bg-background px-2 py-1.5">
<!-- Left: Navigation -->
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
aria-label="Previous"
@click="emit('prev')">
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
aria-label="Next"
@click="emit('next')">
<ChevronRight class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="emit('today')"> today </Button>
</div>
<!-- Center: Title -->
<span data-testid="calendar-title" class="text-base font-semibold text-foreground">{{
viewTitle
}}</span>
<!-- Right: View switcher + Settings -->
<div class="flex items-center gap-1">
<Tabs
:model-value="activeView"
@update:model-value="(v) => emit('change-view', String(v))">
<TabsList class="flex items-center space-x-0.5 sm:space-x-1">
<TabBarItem value="timeGridWeek">week</TabBarItem>
<TabBarItem value="timeGridDay">day</TabBarItem>
</TabsList>
</Tabs>
<CalendarSettingsPopover
:settings="settings"
@update:settings="(v) => emit('update:settings', v)" />
</div>
</div>
</template>

View File

@@ -1,30 +1,28 @@
<script setup lang="ts">
import { computed, inject, type ComputedRef } from 'vue';
import { formatDate, formatHumanReadableDuration } from '../utils/time';
import { formatHumanReadableDuration } from '../utils/time';
import type { Organization } from '@/packages/api/src';
import type { Dayjs } from 'dayjs';
const props = defineProps<{
date: Dayjs;
totalSeconds?: number;
isToday?: boolean;
}>();
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
// Injected organization for formatting settings
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
const intervalFormat = computed(() => organization?.value?.interval_format);
const numberFormat = computed(() => organization?.value?.number_format);
const dateFormat = computed(() => organization?.value?.date_format);
</script>
<template>
<div class="fc-day-header-custom">
<div class="text-xs text-muted-foreground font-medium">
{{ date.format('ddd') }}
<div class="text-sm text-foreground" :class="isToday ? 'font-semibold' : 'font-medium'">
{{ date.format('ddd') }} {{ date.date() }}
</div>
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
<span class="block text-xs text-muted-foreground font-medium mt-1">
<span class="block text-xs text-muted-foreground font-medium mt-0.5">
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
</span>
</div>

View File

@@ -40,7 +40,7 @@ const formattedDuration = computed(() =>
</script>
<template>
<div class="text-2xs leading-tight px-0.5 py-1.5">
<div class="text-2xs leading-tight px-0.5 py-1">
<div class="font-semibold">{{ title }}</div>
<div v-if="projectName" class="font-medium opacity-90">
{{ projectName }}
@@ -51,7 +51,7 @@ const formattedDuration = computed(() =>
<div v-if="clientName" class="opacity-85">
{{ clientName }}
</div>
<div class="opacity-90">
<div class="opacity-90" data-duration>
{{ formattedDuration }}
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
export interface WindowActivityInPeriod {
appName: string;
url: string | null;
count: number;
icon?: string | null;
}
export interface ActivityPeriod {
start: string;
end: string;
isIdle: boolean;
windowActivities?: WindowActivityInPeriod[];
}

View File

@@ -0,0 +1,40 @@
import type { TimeEntry, Project, Client, Task } from '@/packages/api/src';
import type { Dayjs } from 'dayjs';
import type { ActivityPeriod } from './activityTypes';
export const SLOT_HEIGHT = 25;
export const DRAG_THRESHOLD = 5;
export const TIME_AXIS_WIDTH = 48;
export interface CalendarEvent {
id: string;
timeEntry: TimeEntry;
project?: Project;
client?: Client;
task?: Task;
isRunning: boolean;
durationMinutes: number;
title: string;
backgroundColor: string;
borderColor: string;
dayStart: Dayjs;
dayEnd: Dayjs;
}
export interface DayEvent {
event: CalendarEvent;
top: number;
height: number;
left: string;
width: string;
isClippedStart: boolean;
isClippedEnd: boolean;
}
export interface ActivityBox {
dateStr: string;
top: number;
height: number;
isIdle: boolean;
period: ActivityPeriod;
}

View File

@@ -1,393 +0,0 @@
import { createPlugin, type PluginDef } from '@fullcalendar/core';
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
export interface WindowActivityInPeriod {
appName: string;
url: string | null;
count: number;
icon?: string | null;
}
export interface ActivityPeriod {
start: string;
end: string;
isIdle: boolean;
windowActivities?: WindowActivityInPeriod[];
}
export interface ActivityStatusPluginOptions {
activityPeriods?: ActivityPeriod[];
}
// Tooltip state management - single instance per module
let tooltipInstance: HTMLElement | null = null;
let cleanupAutoUpdate: (() => void) | null = null;
/**
* Creates and manages a tooltip element for activity status boxes
*/
function getOrCreateTooltip(): HTMLElement {
if (!tooltipInstance) {
tooltipInstance = document.createElement('div');
tooltipInstance.className =
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';
tooltipInstance.style.position = 'fixed';
tooltipInstance.style.pointerEvents = 'none';
tooltipInstance.style.opacity = '0';
tooltipInstance.style.whiteSpace = 'nowrap';
tooltipInstance.style.transform = 'scale(0.95)';
tooltipInstance.style.transition = 'opacity 150ms, transform 150ms';
document.body.appendChild(tooltipInstance);
}
return tooltipInstance;
}
/**
* Shows tooltip for an activity status box using Floating UI's autoUpdate
*/
function showTooltip(box: HTMLElement, tooltip: HTMLElement, content: string | HTMLElement) {
// Clear previous content
tooltip.innerHTML = '';
if (typeof content === 'string') {
tooltip.textContent = content;
} else {
tooltip.appendChild(content);
}
tooltip.style.opacity = '1';
tooltip.style.transform = 'scale(1)';
// Clean up previous autoUpdate if it exists
if (cleanupAutoUpdate) {
cleanupAutoUpdate();
}
// Use autoUpdate to automatically update position
cleanupAutoUpdate = autoUpdate(box, tooltip, () => {
computePosition(box, tooltip, {
placement: 'right',
middleware: [offset(8), flip(), shift({ padding: 5 })],
}).then(({ x, y }) => {
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
});
});
}
/**
* Hides the tooltip immediately
*/
function hideTooltip(tooltip: HTMLElement) {
tooltip.style.opacity = '0';
tooltip.style.transform = 'scale(0.95)';
// Clean up autoUpdate when tooltip is hidden
if (cleanupAutoUpdate) {
cleanupAutoUpdate();
cleanupAutoUpdate = null;
}
}
/**
* Formats duration in minutes to human readable format
*/
function formatDuration(durationMinutes: number): string {
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
/**
* Creates tooltip content for an activity period
*/
function createTooltipContent(
status: string,
durationText: string,
windowActivities?: WindowActivityInPeriod[]
): string | HTMLElement {
if (!windowActivities || windowActivities.length === 0) {
return `${status} (${durationText})`;
}
const container = document.createElement('div');
container.style.maxWidth = '300px';
// Header with status and duration
const header = document.createElement('div');
header.style.fontWeight = '600';
header.style.marginBottom = '8px';
header.textContent = `${status} (${durationText})`;
container.appendChild(header);
// Window activities list
const totalActivities = windowActivities.reduce((sum, act) => sum + act.count, 0);
// Show top 5 activities
const topActivities = windowActivities.slice(0, 5);
topActivities.forEach((activity) => {
const activityDiv = document.createElement('div');
activityDiv.style.marginTop = '4px';
activityDiv.style.fontSize = '11px';
activityDiv.style.opacity = '0.9';
activityDiv.style.display = 'flex';
activityDiv.style.alignItems = 'center';
activityDiv.style.gap = '6px';
// Add icon if available
if (activity.icon) {
const icon = document.createElement('img');
icon.src = activity.icon;
icon.alt = activity.appName;
icon.style.width = '16px';
icon.style.height = '16px';
icon.style.borderRadius = '2px';
icon.style.flexShrink = '0';
activityDiv.appendChild(icon);
} else {
// Placeholder for no icon
const placeholder = document.createElement('div');
placeholder.style.width = '16px';
placeholder.style.height = '16px';
placeholder.style.borderRadius = '2px';
placeholder.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
placeholder.style.display = 'flex';
placeholder.style.alignItems = 'center';
placeholder.style.justifyContent = 'center';
placeholder.style.fontSize = '8px';
placeholder.style.flexShrink = '0';
placeholder.textContent = activity.appName.charAt(0).toUpperCase();
activityDiv.appendChild(placeholder);
}
const textSpan = document.createElement('span');
textSpan.style.flex = '1';
textSpan.style.overflow = 'hidden';
textSpan.style.textOverflow = 'ellipsis';
textSpan.style.whiteSpace = 'nowrap';
const percentage = ((activity.count / totalActivities) * 100).toFixed(0);
const activityText = activity.url
? `${activity.appName} - ${activity.url}`
: activity.appName;
textSpan.textContent = `${percentage}% ${activityText}`;
activityDiv.appendChild(textSpan);
container.appendChild(activityDiv);
});
// Show "and X more" if there are more activities
if (windowActivities.length > 5) {
const moreDiv = document.createElement('div');
moreDiv.style.marginTop = '4px';
moreDiv.style.fontSize = '11px';
moreDiv.style.opacity = '0.7';
moreDiv.style.fontStyle = 'italic';
moreDiv.textContent = `...and ${windowActivities.length - 5} more`;
container.appendChild(moreDiv);
}
return container;
}
/**
* Renders activity status boxes in the calendar time grid
*/
export function renderActivityStatusBoxes(
calendarEl: HTMLElement,
activityPeriods: ActivityPeriod[]
) {
if (!calendarEl) return;
// Clean up existing activity boxes
const existingBoxes = calendarEl.querySelectorAll('.activity-status-box');
existingBoxes.forEach((box) => box.remove());
// Remove has-activity-status class from all lanes
const allLanes = calendarEl.querySelectorAll('.fc-timegrid-col');
allLanes.forEach((lane) => lane.classList.remove('has-activity-status'));
const timeGrid = calendarEl.querySelector('.fc-timegrid-body');
if (!timeGrid) return;
const lanes = timeGrid.querySelectorAll('.fc-timegrid-col');
if (lanes.length === 0) return;
// Get or reuse the single tooltip instance
const tooltip = getOrCreateTooltip();
// Get slot duration from calendar (fallback to 15 minutes)
const slotDurationMinutes = getSlotDuration(calendarEl);
lanes.forEach((lane: Element) => {
// Get the date for this lane from the data attribute
const laneEl = lane as HTMLElement;
const dateStr = laneEl.getAttribute('data-date');
if (!dateStr) return;
const laneDate = new Date(dateStr);
const laneDateStart = new Date(laneDate);
laneDateStart.setHours(0, 0, 0, 0);
const laneDateEnd = new Date(laneDate);
laneDateEnd.setHours(23, 59, 59, 999);
let hasActivityStatusForThisDay = false;
activityPeriods.forEach((period) => {
const periodStart = new Date(period.start);
const periodEnd = new Date(period.end);
// Check if period overlaps with this day
if (periodEnd < laneDateStart || periodStart > laneDateEnd) {
return;
}
// Calculate actual start and end times for this day
const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;
const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;
// Calculate the position and height of the activity box
const { top, height } = calculateBoxPosition(
calendarEl,
actualStart,
actualEnd,
slotDurationMinutes
);
if (height <= 0) return;
hasActivityStatusForThisDay = true;
// Calculate duration in minutes
const durationMs = actualEnd.getTime() - actualStart.getTime();
const durationMinutes = Math.round(durationMs / 60000);
const durationText = formatDuration(durationMinutes);
// Add tooltip text based on status
const status = period.isIdle ? 'Idling' : 'Active';
// Create and append the activity status box
const box = document.createElement('div');
box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;
box.style.top = `${top}px`;
box.style.height = `${height}px`;
// Store tooltip content generator in data attribute for event delegation
const tooltipContent = createTooltipContent(
status,
durationText,
period.windowActivities
);
// Add hover event listeners for tooltip
box.addEventListener('mouseenter', () => {
showTooltip(box, tooltip, tooltipContent);
});
box.addEventListener('mouseleave', () => {
hideTooltip(tooltip);
});
// Position relative to the lane
const laneFrame = lane.querySelector('.fc-timegrid-col-frame');
if (laneFrame) {
laneFrame.appendChild(box);
}
});
// Mark this lane as having activity status if any periods were rendered
if (hasActivityStatusForThisDay) {
laneEl.classList.add('has-activity-status');
}
});
}
/**
* Gets the slot duration from the calendar configuration
*/
function getSlotDuration(calendarEl: HTMLElement): number {
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
if (slotsEl.length < 2) return 15; // Default to 15 minutes
// Try to calculate from the time difference between slots
const firstSlot = slotsEl[0] as HTMLElement;
const secondSlot = slotsEl[1] as HTMLElement;
const firstTime = firstSlot.getAttribute('data-time');
const secondTime = secondSlot.getAttribute('data-time');
if (firstTime && secondTime) {
const [h1 = 0, m1 = 0] = firstTime.split(':').map(Number);
const [h2 = 0, m2 = 0] = secondTime.split(':').map(Number);
const diff = h2 * 60 + m2 - (h1 * 60 + m1);
if (diff > 0) return diff;
}
// Fallback to 15 minutes
return 15;
}
/**
* Calculates the pixel position and height for an activity status box
*/
function calculateBoxPosition(
calendarEl: HTMLElement,
startTime: Date,
endTime: Date,
slotDurationMinutes: number
): { top: number; height: number } {
// Get the slot duration and slot height
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
if (slotsEl.length === 0) {
return { top: 0, height: 0 };
}
// Calculate slot height (assuming all slots are equal height)
const firstSlot = slotsEl[0] as HTMLElement;
const slotHeight = firstSlot.offsetHeight;
const pixelsPerMinute = slotHeight / slotDurationMinutes;
// Calculate start position (minutes from midnight)
const startMinutes = startTime.getHours() * 60 + startTime.getMinutes();
const endMinutes = endTime.getHours() * 60 + endTime.getMinutes();
// Calculate pixel positions
const top = startMinutes * pixelsPerMinute;
const height = (endMinutes - startMinutes) * pixelsPerMinute;
return { top, height };
}
/**
* Cleanup function to remove tooltip from DOM
*/
export function cleanupActivityStatusPlugin() {
if (tooltipInstance) {
tooltipInstance.remove();
tooltipInstance = null;
}
if (cleanupAutoUpdate) {
cleanupAutoUpdate();
cleanupAutoUpdate = null;
}
}
/**
* FullCalendar plugin to display idle/active status boxes in the time grid
*/
const activityStatusPlugin: PluginDef = createPlugin({
name: '@solidtime/activity-status',
optionRefiners: {
activityPeriods: (rawVal: unknown): ActivityPeriod[] => {
if (!Array.isArray(rawVal)) return [];
return rawVal as ActivityPeriod[];
},
},
});
export default activityStatusPlugin;

View File

@@ -0,0 +1,103 @@
import { computed, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs';
import type { ActivityPeriod, WindowActivityInPeriod } from './activityTypes';
import type { CalendarSettings } from './calendarSettings';
import type { ActivityBox } from './calendarTypes';
import type { Ref } from 'vue';
import { getLocalizedDayJs } from '../utils/time';
export function useActivityBoxes(params: {
activityPeriods: () => ActivityPeriod[] | undefined;
viewDays: ComputedRef<Dayjs[]>;
calendarSettings: Ref<CalendarSettings>;
minutesToPixels: (minutes: number) => number;
}) {
function formatActivityDuration(durationMinutes: number): string {
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function getActivityBoxLabel(box: ActivityBox): string {
const periodStart = getLocalizedDayJs(box.period.start);
const periodEnd = getLocalizedDayJs(box.period.end);
const durationMinutes = Math.round(periodEnd.diff(periodStart, 'minute', true));
const durationText = formatActivityDuration(durationMinutes);
const status = box.isIdle ? 'Idling' : 'Active';
return `${status} (${durationText})`;
}
function getActivityBoxActivities(box: ActivityBox) {
return box.period.windowActivities ?? [];
}
function getActivityPercentage(count: number, total: number): string {
if (total === 0) return '0';
return ((count / total) * 100).toFixed(0);
}
function getActivityText(activity: WindowActivityInPeriod): string {
return activity.url ? `${activity.appName} - ${activity.url}` : activity.appName;
}
const activityBoxes = computed<ActivityBox[]>(() => {
const periods = params.activityPeriods();
if (!periods || periods.length === 0) return [];
const s = params.calendarSettings.value;
const startMin = s.startHour * 60;
const endMin = s.endHour * 60;
const boxes: ActivityBox[] = [];
for (const day of params.viewDays.value) {
const dateStr = day.format('YYYY-MM-DD');
const dayStart = day.startOf('day');
const dayEnd = day.endOf('day');
for (const period of periods) {
const periodStart = getLocalizedDayJs(period.start);
const periodEnd = getLocalizedDayJs(period.end);
if (periodEnd.isBefore(dayStart) || periodStart.isAfter(dayEnd)) continue;
const actualStart = periodStart.isAfter(dayStart) ? periodStart : dayStart;
const actualEnd = periodEnd.isBefore(dayEnd) ? periodEnd : dayEnd;
const actualStartMin = actualStart.hour() * 60 + actualStart.minute();
const actualEndMin = actualEnd.hour() * 60 + actualEnd.minute();
const clampedStart = Math.max(actualStartMin, startMin);
const clampedEnd = Math.min(actualEndMin, endMin);
if (clampedEnd <= clampedStart) continue;
const top = params.minutesToPixels(clampedStart - startMin);
const height = params.minutesToPixels(clampedEnd - clampedStart);
if (height > 0) {
boxes.push({ dateStr, top, height, isIdle: period.isIdle, period });
}
}
}
return boxes;
});
function activityBoxesForDay(dateStr: string): ActivityBox[] {
return activityBoxes.value.filter((b) => b.dateStr === dateStr);
}
function dayHasActivityStatus(dateStr: string): boolean {
return activityBoxes.value.some((b) => b.dateStr === dateStr);
}
return {
activityBoxes,
activityBoxesForDay,
dayHasActivityStatus,
getActivityBoxLabel,
getActivityBoxActivities,
getActivityPercentage,
getActivityText,
};
}

View File

@@ -0,0 +1,300 @@
import { computed, ref, type Ref, type ComputedRef } from 'vue';
import chroma from 'chroma-js';
import type { Dayjs } from 'dayjs';
import type { TimeEntry, Project, Client, Task } from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent, DayEvent } from './calendarTypes';
interface PositionedEvent {
event: CalendarEvent;
startMin: number;
endMin: number;
isClippedStart: boolean;
isClippedEnd: boolean;
}
interface ColumnAssignment extends PositionedEvent {
col: number;
}
/** Clip an event's time range to a single day and the visible hour range. */
function clipEventToDay(
ev: CalendarEvent,
dayStart: Dayjs,
dayEnd: Dayjs,
visibleStartMin: number,
visibleEndMin: number,
timeToMinutesFromMidnight: (time: Dayjs) => number
): PositionedEvent {
const isClippedStart = ev.dayStart.isBefore(dayStart);
const isClippedEnd = ev.dayEnd.isAfter(dayEnd);
let evStartMin = isClippedStart ? 0 : timeToMinutesFromMidnight(ev.dayStart);
let evEndMin = isClippedEnd ? 24 * 60 : timeToMinutesFromMidnight(ev.dayEnd);
evStartMin = Math.max(evStartMin, visibleStartMin);
evEndMin = Math.min(evEndMin, visibleEndMin);
if (evEndMin <= evStartMin) {
evEndMin = evStartMin + 1;
}
return { event: ev, startMin: evStartMin, endMin: evEndMin, isClippedStart, isClippedEnd };
}
/** Greedily assign each event to the first column where it fits without overlap. */
function assignColumns(positioned: PositionedEvent[]): ColumnAssignment[] {
const columns: PositionedEvent[][] = [];
const result: ColumnAssignment[] = [];
for (const item of positioned) {
let placed = false;
for (let c = 0; c < columns.length; c++) {
const lastInCol = columns[c]![columns[c]!.length - 1]!;
if (lastInCol.endMin <= item.startMin) {
columns[c]!.push(item);
result.push({ ...item, col: c });
placed = true;
break;
}
}
if (!placed) {
columns.push([item]);
result.push({ ...item, col: columns.length - 1 });
}
}
return result;
}
/** Convert column-assigned groups into pixel-positioned DayEvent objects. */
function groupsToDayEvents(
groups: { items: ColumnAssignment[]; totalCols: number }[],
visibleStartMin: number,
minutesToPixels: (minutes: number) => number
): DayEvent[] {
const result: DayEvent[] = [];
for (const group of groups) {
for (const item of group.items) {
const top = minutesToPixels(item.startMin - visibleStartMin);
const height = minutesToPixels(item.endMin - item.startMin);
result.push({
event: item.event,
top,
height: Math.max(height, 1),
left: `${(item.col / group.totalCols) * 100}%`,
width: `${(1 / group.totalCols) * 100}%`,
isClippedStart: item.isClippedStart,
isClippedEnd: item.isClippedEnd,
});
}
}
return result;
}
/** Compute positioned events for a single day. */
function layoutDayEvents(
dayEvents: CalendarEvent[],
dayStart: Dayjs,
dayEnd: Dayjs,
visibleStartMin: number,
visibleEndMin: number,
timeToMinutesFromMidnight: (time: Dayjs) => number,
minutesToPixels: (minutes: number) => number
): DayEvent[] {
const positioned = dayEvents.map((ev) =>
clipEventToDay(
ev,
dayStart,
dayEnd,
visibleStartMin,
visibleEndMin,
timeToMinutesFromMidnight
)
);
// Sort: earliest start first, then longest duration first (for stable column assignment)
positioned.sort((a, b) => {
if (a.startMin !== b.startMin) return a.startMin - b.startMin;
return b.endMin - b.startMin - (a.endMin - a.startMin);
});
const eventColumns = assignColumns(positioned);
const groups = groupOverlappingEvents(eventColumns);
return groupsToDayEvents(groups, visibleStartMin, minutesToPixels);
}
/** Group events that transitively overlap so each group shares column count. */
function groupOverlappingEvents(
eventColumns: ColumnAssignment[]
): { items: ColumnAssignment[]; totalCols: number }[] {
const groups: { items: ColumnAssignment[]; totalCols: number }[] = [];
const assigned = new Set<number>();
for (let i = 0; i < eventColumns.length; i++) {
if (assigned.has(i)) continue;
const group = [eventColumns[i]!];
assigned.add(i);
let expanded = true;
while (expanded) {
expanded = false;
for (let j = 0; j < eventColumns.length; j++) {
if (assigned.has(j)) continue;
const candidate = eventColumns[j]!;
for (const member of group) {
if (candidate.startMin < member.endMin && candidate.endMin > member.startMin) {
group.push(candidate);
assigned.add(j);
expanded = true;
break;
}
}
}
}
let maxCol = 0;
for (const item of group) {
if (item.col > maxCol) maxCol = item.col;
}
groups.push({ items: group, totalCols: maxCol + 1 });
}
return groups;
}
export function useCalendarEvents(params: {
timeEntries: () => TimeEntry[];
projects: () => Project[];
clients: () => Client[];
tasks: () => Task[];
calendarSettings: Ref<CalendarSettings>;
viewDays: ComputedRef<Dayjs[]>;
currentTime: Ref<Dayjs>;
cssBackground: Ref<string>;
minutesToPixels: (minutes: number) => number;
timeToMinutesFromMidnight: (time: Dayjs) => number;
}) {
const optimisticOverrides = ref<Map<string, TimeEntry>>(new Map());
const calendarEvents = computed<CalendarEvent[]>(() => {
const themeBackground = params.cssBackground.value?.trim();
return params.timeEntries().map((rawEntry) => {
const timeEntry = optimisticOverrides.value.get(rawEntry.id) || rawEntry;
const isRunning = timeEntry.end === null;
const project = params.projects().find((p) => p.id === timeEntry.project_id);
const client = params.clients().find((c) => c.id === project?.client_id);
const task = params.tasks().find((t) => t.id === timeEntry.task_id);
const effectiveEnd = isRunning
? params.currentTime.value
: getDayJsInstance()(timeEntry.end!);
const durationMinutes = effectiveEnd.diff(
getDayJsInstance()(timeEntry.start),
'minutes'
);
const title = timeEntry.description || 'No description';
const baseColor = project?.color || '#6B7280';
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
const startTime = getLocalizedDayJs(timeEntry.start);
const endTime = isRunning
? getLocalizedDayJs(params.currentTime.value.toISOString())
: durationMinutes === 0
? startTime.add(1, 'second')
: getLocalizedDayJs(timeEntry.end!);
return {
id: timeEntry.id,
timeEntry,
project,
client,
task,
isRunning,
durationMinutes,
title,
backgroundColor,
borderColor,
dayStart: startTime,
dayEnd: endTime,
};
});
});
const eventsByDay = computed(() => {
const s = params.calendarSettings.value;
const visibleStartMin = s.startHour * 60;
const visibleEndMin = s.endHour * 60;
const result: Record<string, DayEvent[]> = {};
for (const day of params.viewDays.value) {
const dayStart = day.startOf('day');
const dayEnd = day.endOf('day');
const dayEvents = calendarEvents.value.filter(
(ev) => ev.dayStart.isBefore(dayEnd) && ev.dayEnd.isAfter(dayStart)
);
result[day.format('YYYY-MM-DD')] = layoutDayEvents(
dayEvents,
dayStart,
dayEnd,
visibleStartMin,
visibleEndMin,
params.timeToMinutesFromMidnight,
params.minutesToPixels
);
}
return result;
});
const dailyTotals = computed(() => {
const totals: Record<string, number> = {};
params.timeEntries().forEach((entry) => {
const date = getLocalizedDayJs(entry.start).format('YYYY-MM-DD');
let durationSeconds: number;
if (entry.end !== null) {
durationSeconds = getDayJsInstance()(entry.end).diff(
getDayJsInstance()(entry.start),
'seconds'
);
} else {
durationSeconds = params.currentTime.value.diff(
getDayJsInstance()(entry.start),
'seconds'
);
}
totals[date] = (totals[date] || 0) + durationSeconds;
});
return totals;
});
function isToday(day: Dayjs): boolean {
return day.isSame(getLocalizedDayJs(), 'day');
}
const nowIndicatorTop = computed(() => {
const s = params.calendarSettings.value;
const now = getLocalizedDayJs(params.currentTime.value.toISOString());
const minutesFromMidnight = now.hour() * 60 + now.minute();
const startMin = s.startHour * 60;
if (minutesFromMidnight < startMin || minutesFromMidnight >= s.endHour * 60) return -1;
return params.minutesToPixels(minutesFromMidnight - startMin);
});
return {
optimisticOverrides,
calendarEvents,
eventsByDay,
dailyTotals,
isToday,
nowIndicatorTop,
};
}

View File

@@ -0,0 +1,137 @@
import { computed, type Ref } from 'vue';
import type { ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs';
import type { Organization } from '@/packages/api/src';
import type { CalendarSettings } from './calendarSettings';
import { SLOT_HEIGHT } from './calendarTypes';
export function useCalendarGrid(
calendarSettings: Ref<CalendarSettings>,
organization: ComputedRef<Organization> | undefined,
scrollerRef: Ref<HTMLElement | null>,
rootRef: Ref<HTMLElement | null>
) {
const slots = computed(() => {
const s = calendarSettings.value;
const result: { time: string; isHour: boolean; minutes: number }[] = [];
const startMin = s.startHour * 60;
const endMin = s.endHour * 60;
for (let m = startMin; m < endMin; m += s.slotMinutes) {
const hours = Math.floor(m / 60);
const mins = m % 60;
const time = `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}:00`;
const isHour = mins === 0;
result.push({ time, isHour, minutes: m });
}
return result;
});
const totalGridHeight = computed(() => slots.value.length * SLOT_HEIGHT);
function formatSlotLabel(hour: number): string {
const timeFormat = organization?.value?.time_format || '24-hours';
if (timeFormat === '12-hours') {
const period = hour >= 12 ? 'PM' : 'AM';
const h = hour % 12 || 12;
return `${h} ${period}`;
}
return `${String(hour).padStart(2, '0')}:00`;
}
function minutesToPixels(minutes: number): number {
const s = calendarSettings.value;
return (minutes / s.slotMinutes) * SLOT_HEIGHT;
}
function pixelsToMinutesFromMidnight(px: number): number {
const s = calendarSettings.value;
return (px / SLOT_HEIGHT) * s.slotMinutes + s.startHour * 60;
}
function timeToMinutesFromMidnight(time: Dayjs): number {
return time.hour() * 60 + time.minute() + time.second() / 60;
}
function snapStartToGrid(time: Dayjs, snapMinutes: number): Dayjs {
const minutes = time.hour() * 60 + time.minute();
const snapped = Math.floor(minutes / snapMinutes) * snapMinutes;
return time.startOf('day').add(snapped, 'minute');
}
function snapEndToGrid(time: Dayjs, snapMinutes: number): Dayjs {
const minutes = time.hour() * 60 + time.minute();
const snapped = Math.ceil(minutes / snapMinutes) * snapMinutes;
return time.startOf('day').add(snapped, 'minute');
}
function snapToNearestGrid(time: Dayjs, snapMinutes: number): Dayjs {
const minutes = time.hour() * 60 + time.minute();
const snapped = Math.round(minutes / snapMinutes) * snapMinutes;
return time.startOf('day').add(snapped, 'minute');
}
function getDayColumnBounds(): { dateStr: string; left: number; right: number }[] {
if (!rootRef.value) return [];
const cols = rootRef.value.querySelectorAll<HTMLElement>('.fc-timegrid-col');
const bounds: { dateStr: string; left: number; right: number }[] = [];
cols.forEach((col) => {
const rect = col.getBoundingClientRect();
bounds.push({
dateStr: col.dataset.date || '',
left: rect.left,
right: rect.right,
});
});
return bounds;
}
function getDayFromClientX(clientX: number): string | null {
const bounds = getDayColumnBounds();
for (const b of bounds) {
if (clientX >= b.left && clientX < b.right) {
return b.dateStr;
}
}
let closest: string | null = null;
let minDist = Infinity;
for (const b of bounds) {
const center = (b.left + b.right) / 2;
const dist = Math.abs(clientX - center);
if (dist < minDist) {
minDist = dist;
closest = b.dateStr;
}
}
return closest;
}
function getScrollerTop(): number {
if (!scrollerRef.value) return 0;
return scrollerRef.value.getBoundingClientRect().top + scrollerRef.value.scrollTop;
}
function clientYToGridPixels(clientY: number): number {
if (!scrollerRef.value) return 0;
const scrollerRect = scrollerRef.value.getBoundingClientRect();
return clientY - scrollerRect.top + scrollerRef.value.scrollTop;
}
return {
slots,
totalGridHeight,
formatSlotLabel,
minutesToPixels,
pixelsToMinutesFromMidnight,
timeToMinutesFromMidnight,
snapStartToGrid,
snapEndToGrid,
snapToNearestGrid,
getDayColumnBounds,
getDayFromClientX,
getScrollerTop,
clientYToGridPixels,
};
}

View File

@@ -0,0 +1,118 @@
import { computed, ref } from 'vue';
import type { Dayjs } from 'dayjs';
import { getLocalizedDayJs } from '../utils/time';
import { getWeekStart } from '../utils/settings';
export function useCalendarNavigation(callbacks: {
onDatesChange: (payload: { start: Date; end: Date }) => void;
scrollToCurrentTime: () => void;
}) {
const activeView = ref('timeGridWeek');
const currentDate = ref(getLocalizedDayJs());
function getFirstDay(): number {
const weekStart = getWeekStart();
const weekStartMap: Record<string, number> = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6,
};
return weekStartMap[weekStart] ?? 1;
}
const viewDays = computed<Dayjs[]>(() => {
const numDays = activeView.value === 'timeGridWeek' ? 7 : 1;
if (numDays === 1) {
return [currentDate.value.startOf('day')];
}
const firstDay = getFirstDay();
const today = currentDate.value.startOf('day');
const offset = (today.day() - firstDay + 7) % 7;
const weekStart = today.subtract(offset, 'day');
const days: Dayjs[] = [];
for (let i = 0; i < numDays; i++) {
days.push(weekStart.add(i, 'day'));
}
return days;
});
const viewTitle = computed(() => {
if (activeView.value === 'timeGridDay') {
return currentDate.value.format('MMMM YYYY');
}
const days = viewDays.value;
if (days.length === 0) return '';
const first = days[0]!;
const last = days[days.length - 1]!;
if (first.year() !== last.year()) {
return `${first.format('MMM YYYY')} \u2013 ${last.format('MMM YYYY')}`;
}
if (first.month() !== last.month()) {
return `${first.format('MMM')} \u2013 ${last.format('MMM YYYY')}`;
}
return first.format('MMMM YYYY');
});
function emitDatesChange() {
const days = viewDays.value;
if (days.length === 0) return;
const start = days[0]!.toDate();
const end = days[days.length - 1]!.add(1, 'day').toDate();
callbacks.onDatesChange({ start, end });
}
function handlePrev() {
if (activeView.value === 'timeGridWeek') {
currentDate.value = currentDate.value.subtract(7, 'day');
} else {
currentDate.value = currentDate.value.subtract(1, 'day');
}
emitDatesChange();
callbacks.scrollToCurrentTime();
}
function handleNext() {
if (activeView.value === 'timeGridWeek') {
currentDate.value = currentDate.value.add(7, 'day');
} else {
currentDate.value = currentDate.value.add(1, 'day');
}
emitDatesChange();
callbacks.scrollToCurrentTime();
}
function handleToday() {
currentDate.value = getLocalizedDayJs();
emitDatesChange();
callbacks.scrollToCurrentTime();
}
function handleChangeView(view: string) {
activeView.value = view;
emitDatesChange();
callbacks.scrollToCurrentTime();
}
return {
activeView,
currentDate,
viewDays,
viewTitle,
emitDatesChange,
handlePrev,
handleNext,
handleToday,
handleChangeView,
};
}

View File

@@ -0,0 +1,172 @@
import { ref, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance } from '../utils/time';
import { getUserTimezone } from '../utils/settings';
import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent } from './calendarTypes';
export function useContextMenu(params: {
calendarSettings: Ref<CalendarSettings>;
calendarEvents: ComputedRef<CalendarEvent[]>;
pixelsToMinutesFromMidnight: (px: number) => number;
getDayFromClientX: (clientX: number) => string | null;
clientYToGridPixels: (clientY: number) => number;
createTimeEntry: (
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
) => Promise<void>;
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
deleteTimeEntry: (id: string) => Promise<void>;
onEditEvent: (entry: TimeEntry) => void;
onCreateEvent: (start: Dayjs, end: Dayjs) => void;
emitRefresh: () => void;
}) {
const contextMenuTimeEntry = ref<TimeEntry | null>(null);
const contextMenuCreateTime = ref<{ start: Dayjs; end: Dayjs } | null>(null);
function getTimeAtClickPosition(event: MouseEvent): { start: Dayjs; end: Dayjs } | null {
const date = params.getDayFromClientX(event.clientX);
if (!date) return null;
const gridY = params.clientYToGridPixels(event.clientY);
const minutesFromGridStart = params.pixelsToMinutesFromMidnight(gridY);
const snap = params.calendarSettings.value.snapMinutes;
const snappedMinutes = Math.floor(minutesFromGridStart / snap) * snap;
const dayjs = getDayJsInstance();
const startLocal = dayjs(`${date}T00:00:00`)
.tz(getUserTimezone(), true)
.add(snappedMinutes, 'minute');
const snappedEnd = startLocal.add(snap, 'minute');
return { start: startLocal.utc(), end: snappedEnd.utc() };
}
function handleCalendarContextMenu(event: MouseEvent) {
const target = event.target as HTMLElement;
const eventEl = target.closest<HTMLElement>('[data-event-id]');
if (!eventEl) {
contextMenuTimeEntry.value = null;
const timeInfo = getTimeAtClickPosition(event);
contextMenuCreateTime.value = timeInfo;
return;
}
const eventId = eventEl.getAttribute('data-event-id');
if (!eventId) return;
const ev = params.calendarEvents.value.find((e) => e.id === eventId);
if (!ev) return;
contextMenuTimeEntry.value = ev.timeEntry;
contextMenuCreateTime.value = null;
}
function handleContextEdit() {
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
params.onEditEvent(contextMenuTimeEntry.value);
}
async function handleContextDuplicate() {
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
const entry = contextMenuTimeEntry.value;
await params.createTimeEntry({
start: entry.start,
end: entry.end,
billable: entry.billable,
description: entry.description,
project_id: entry.project_id,
task_id: entry.task_id,
tags: entry.tags,
});
params.emitRefresh();
}
async function handleContextDelete() {
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
await params.deleteTimeEntry(contextMenuTimeEntry.value.id);
params.emitRefresh();
}
async function handleContextSplit() {
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
const entry = contextMenuTimeEntry.value;
if (!entry.end) return;
const start = getDayJsInstance()(entry.start);
const end = getDayJsInstance()(entry.end);
const midpoint = start.add(end.diff(start) / 2, 'millisecond').startOf('minute');
try {
await params.updateTimeEntry({ ...entry, end: midpoint.utc().format() });
} catch {
// Update failed, don't proceed with create
params.emitRefresh();
return;
}
try {
await params.createTimeEntry({
start: midpoint.utc().format(),
end: entry.end,
billable: entry.billable,
description: entry.description,
project_id: entry.project_id,
task_id: entry.task_id,
tags: entry.tags,
});
} catch {
// Create failed after update succeeded — restore original entry
try {
await params.updateTimeEntry({ ...entry });
} catch {
// Restoration also failed; refresh will show server state
}
}
params.emitRefresh();
}
async function handleContextStop() {
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end !== null) return;
const entry = contextMenuTimeEntry.value;
await params.updateTimeEntry({
...entry,
end: getDayJsInstance()().utc().format(),
});
params.emitRefresh();
}
async function handleContextDiscard() {
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end !== null) return;
await params.deleteTimeEntry(contextMenuTimeEntry.value.id);
params.emitRefresh();
}
function handleContextCreate() {
if (contextMenuCreateTime.value) {
params.onCreateEvent(
contextMenuCreateTime.value.start,
contextMenuCreateTime.value.end
);
} else {
params.onCreateEvent(
getDayJsInstance()().utc(),
getDayJsInstance()().utc().add(1, 'hour')
);
}
}
return {
contextMenuTimeEntry,
contextMenuCreateTime,
handleCalendarContextMenu,
handleContextEdit,
handleContextDuplicate,
handleContextDelete,
handleContextSplit,
handleContextStop,
handleContextDiscard,
handleContextCreate,
};
}

View File

@@ -0,0 +1,295 @@
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
import { getUserTimezone } from '../utils/settings';
import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent, DayEvent } from './calendarTypes';
import { SLOT_HEIGHT, DRAG_THRESHOLD } from './calendarTypes';
export function useEventDrag(params: {
calendarSettings: Ref<CalendarSettings>;
viewDays: ComputedRef<Dayjs[]>;
optimisticOverrides: Ref<Map<string, TimeEntry>>;
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
emitRefresh: () => void;
minutesToPixels: (minutes: number) => number;
pixelsToMinutesFromMidnight: (px: number) => number;
getDayFromClientX: (clientX: number) => string | null;
clientYToGridPixels: (clientY: number) => number;
onClickEvent: (ev: CalendarEvent) => void;
}) {
const isDragging = ref(false);
const dragEventId = ref<string | null>(null);
const dragOffsetMinutes = ref(0);
const dragCurrentTop = ref(0);
const dragCurrentDay = ref<string | null>(null);
const dragOriginalDayStr = ref<string | null>(null);
const dragOriginalHeight = ref(0);
const dragVisibleDurationMinutes = ref(0);
// Non-reactive state
let dragStartClientX = 0;
let dragStartClientY = 0;
let dragStartEventTop = 0;
let dragOriginalEvent: CalendarEvent | null = null;
let dragFullDurationMinutes = 0;
let dragEventStartOffsetMinutes = 0;
let hasMoved = false;
function onEventPointerDown(e: PointerEvent, ev: CalendarEvent, dayEvent: DayEvent) {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('.fc-event-resizer')) return;
if (ev.isRunning) return;
e.preventDefault();
dragStartClientX = e.clientX;
dragStartClientY = e.clientY;
dragStartEventTop = dayEvent.top;
dragOriginalEvent = ev;
hasMoved = false;
dragOriginalHeight.value = dayEvent.height;
const s = params.calendarSettings.value;
dragVisibleDurationMinutes.value = (dayEvent.height / SLOT_HEIGHT) * s.slotMinutes;
const originDay = params.getDayFromClientX(e.clientX);
dragOriginalDayStr.value = originDay;
if (ev.timeEntry.end) {
const evStart = getLocalizedDayJs(ev.timeEntry.start);
const evEnd = getLocalizedDayJs(ev.timeEntry.end);
dragFullDurationMinutes = evEnd.diff(evStart, 'minute');
} else {
dragFullDurationMinutes = dragVisibleDurationMinutes.value;
}
if (dayEvent.isClippedStart && originDay && ev.timeEntry.end) {
const dayjs = getDayJsInstance();
const dayMidnight = dayjs(`${originDay}T00:00:00`).tz(getUserTimezone(), true);
const evStart = getLocalizedDayJs(ev.timeEntry.start);
const eventStartFromGridStart = evStart.diff(dayMidnight, 'minute') - s.startHour * 60;
const segmentTopMinutes = (dayEvent.top / SLOT_HEIGHT) * s.slotMinutes;
dragEventStartOffsetMinutes = segmentTopMinutes - eventStartFromGridStart;
} else {
dragEventStartOffsetMinutes = 0;
}
const gridY = params.clientYToGridPixels(e.clientY);
dragOffsetMinutes.value =
params.pixelsToMinutesFromMidnight(gridY) -
params.pixelsToMinutesFromMidnight(dayEvent.top);
document.addEventListener('pointermove', onDragPointerMove);
document.addEventListener('pointerup', onDragPointerUp);
}
function onDragPointerMove(e: PointerEvent) {
const dx = e.clientX - dragStartClientX;
const dy = e.clientY - dragStartClientY;
if (!hasMoved && Math.sqrt(dx * dx + dy * dy) < DRAG_THRESHOLD) {
return;
}
if (!hasMoved) {
hasMoved = true;
isDragging.value = true;
dragEventId.value = dragOriginalEvent!.id;
}
const gridY = params.clientYToGridPixels(e.clientY);
const s = params.calendarSettings.value;
const startMin = s.startHour * 60;
const rawMinutes = params.pixelsToMinutesFromMidnight(gridY) - dragOffsetMinutes.value;
const snappedMinutes = Math.floor(rawMinutes / s.snapMinutes) * s.snapMinutes;
const lowerBound = startMin - 4 * 60;
const clampedMinutes = Math.max(lowerBound, Math.min(snappedMinutes, s.endHour * 60));
dragCurrentTop.value = params.minutesToPixels(clampedMinutes - startMin);
const dayStr = params.getDayFromClientX(e.clientX);
if (dayStr) {
dragCurrentDay.value = dayStr;
}
}
async function onDragPointerUp(e: PointerEvent) {
document.removeEventListener('pointermove', onDragPointerMove);
document.removeEventListener('pointerup', onDragPointerUp);
if (!hasMoved) {
isDragging.value = false;
dragEventId.value = null;
dragOriginalDayStr.value = null;
dragCurrentDay.value = null;
if (dragOriginalEvent && !dragOriginalEvent.isRunning) {
params.onClickEvent(dragOriginalEvent);
}
return;
}
const targetDateStr =
dragCurrentDay.value ||
dragOriginalDayStr.value ||
params.viewDays.value[0]!.format('YYYY-MM-DD');
const savedOriginalDayStr = dragOriginalDayStr.value || targetDateStr;
isDragging.value = false;
dragEventId.value = null;
dragOriginalDayStr.value = null;
dragCurrentDay.value = null;
if (!dragOriginalEvent) return;
const timeEntry = dragOriginalEvent.timeEntry;
if (!timeEntry.end) return;
const s = params.calendarSettings.value;
const gridY = params.clientYToGridPixels(e.clientY);
const rawMinutes = params.pixelsToMinutesFromMidnight(gridY) - dragOffsetMinutes.value;
const snappedMinutes = Math.floor(rawMinutes / s.snapMinutes) * s.snapMinutes;
const startMin = s.startHour * 60;
const lowerBound = startMin - 4 * 60;
const clampedMinutes = Math.max(lowerBound, Math.min(snappedMinutes, s.endHour * 60));
const dayjs = getDayJsInstance();
const originalSegmentStart = dayjs(`${savedOriginalDayStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(startMin + params.pixelsToMinutesFromMidnight(dragStartEventTop), 'minute');
const newSegmentStart = dayjs(`${targetDateStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(clampedMinutes, 'minute');
const deltaMs = newSegmentStart.diff(originalSegmentStart);
const origStart = getLocalizedDayJs(timeEntry.start);
const origEnd = getLocalizedDayJs(timeEntry.end);
const durationMs = origEnd.diff(origStart);
const newStartLocal = origStart.add(deltaMs, 'millisecond');
const newEndLocal = newStartLocal.add(durationMs, 'millisecond');
const updatedTimeEntry = {
...timeEntry,
start: newStartLocal.utc().format(),
end: newEndLocal.utc().format(),
} as TimeEntry;
params.optimisticOverrides.value = new Map(params.optimisticOverrides.value).set(
updatedTimeEntry.id,
updatedTimeEntry
);
try {
await params.updateTimeEntry(updatedTimeEntry);
} catch {
// Revert optimistic override on failure; mutation layer already shows error notification
const reverted = new Map(params.optimisticOverrides.value);
reverted.delete(updatedTimeEntry.id);
params.optimisticOverrides.value = reverted;
}
params.emitRefresh();
}
/**
* Computes a preview style for every day column that the dragged event
* would span. Derives the actual start/end datetime of the moved event,
* then clips each view day's grid to show the visible portion.
*/
const dragPreviewsByDay = computed<Record<string, Record<string, string>>>(() => {
if (!isDragging.value || !dragOriginalEvent) return {};
if (!dragCurrentDay.value) return {};
const s = params.calendarSettings.value;
const gridTotalMinutes = (s.endHour - s.startHour) * 60;
const startMin = s.startHour * 60;
const currentTopMinutes = (dragCurrentTop.value / SLOT_HEIGHT) * s.slotMinutes;
const offset =
dragCurrentDay.value === dragOriginalDayStr.value ? dragEventStartOffsetMinutes : 0;
// Minutes from grid-start on cursor day where the event visually begins
const eventStartOnGrid = currentTopMinutes - offset;
const baseStyle = {
position: 'absolute',
left: '0',
right: '0',
backgroundColor: dragOriginalEvent.backgroundColor,
borderColor: dragOriginalEvent.borderColor,
opacity: '0.7',
zIndex: '100',
borderRadius: 'calc(var(--radius) - 4px)',
border: '1px solid var(--border)',
};
// Single-day fast path: event fits within cursor day's grid
const eventEndOnGrid = eventStartOnGrid + dragFullDurationMinutes;
if (eventEndOnGrid <= gridTotalMinutes && eventStartOnGrid >= 0) {
const previewTop = params.minutesToPixels(eventStartOnGrid);
const previewHeight = params.minutesToPixels(
Math.max(s.snapMinutes, dragFullDurationMinutes)
);
return {
[dragCurrentDay.value]: {
...baseStyle,
top: `${previewTop}px`,
height: `${previewHeight}px`,
},
};
}
// Multi-day: compute actual start/end datetimes, then clip per day
const dayjs = getDayJsInstance();
const eventStartAbsolute = dayjs(`${dragCurrentDay.value}T00:00:00`)
.tz(getUserTimezone(), true)
.add(startMin + eventStartOnGrid, 'minute');
const eventEndAbsolute = eventStartAbsolute.add(dragFullDurationMinutes, 'minute');
const result: Record<string, Record<string, string>> = {};
for (const viewDay of params.viewDays.value) {
const dayStr = viewDay.format('YYYY-MM-DD');
const dayGridStart = viewDay.startOf('day').add(s.startHour, 'hour');
const dayGridEnd = viewDay.startOf('day').add(s.endHour, 'hour');
// Does the event overlap this day's grid window?
if (eventEndAbsolute.isAfter(dayGridStart) && eventStartAbsolute.isBefore(dayGridEnd)) {
const segStart = eventStartAbsolute.isAfter(dayGridStart)
? eventStartAbsolute
: dayGridStart;
const segEnd = eventEndAbsolute.isBefore(dayGridEnd)
? eventEndAbsolute
: dayGridEnd;
const segStartMin = segStart.diff(dayGridStart, 'minute');
const segEndMin = segEnd.diff(dayGridStart, 'minute');
const segHeight = Math.max(s.snapMinutes, segEndMin - segStartMin);
result[dayStr] = {
...baseStyle,
top: `${params.minutesToPixels(segStartMin)}px`,
height: `${params.minutesToPixels(segHeight)}px`,
};
}
}
return result;
});
onUnmounted(() => {
document.removeEventListener('pointermove', onDragPointerMove);
document.removeEventListener('pointerup', onDragPointerUp);
});
return {
isDragging,
dragEventId,
dragCurrentTop,
dragCurrentDay,
dragOriginalDayStr,
dragOriginalHeight,
dragVisibleDurationMinutes,
dragPreviewsByDay,
onEventPointerDown,
};
}

View File

@@ -0,0 +1,363 @@
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
import { getUserTimezone } from '../utils/settings';
import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent, DayEvent } from './calendarTypes';
import { SLOT_HEIGHT } from './calendarTypes';
function snapTo(value: number, step: number): number {
return Math.round(value / step) * step;
}
function dayMidnightLocal(dayStr: string): Dayjs {
return getDayJsInstance()(`${dayStr}T00:00:00`).tz(getUserTimezone(), true);
}
export function useEventResize(params: {
calendarSettings: Ref<CalendarSettings>;
viewDays: ComputedRef<Dayjs[]>;
eventsByDay: ComputedRef<Record<string, DayEvent[]>>;
optimisticOverrides: Ref<Map<string, TimeEntry>>;
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
emitRefresh: () => void;
minutesToPixels: (minutes: number) => number;
pixelsToMinutesFromMidnight: (px: number) => number;
getDayFromClientX: (clientX: number) => string | null;
clientYToGridPixels: (clientY: number) => number;
}) {
const isResizing = ref(false);
const resizeEventId = ref<string | null>(null);
const resizeEdge = ref<'start' | 'end'>('end');
const resizeCurrentTop = ref(0);
const resizeCurrentHeight = ref(0);
const resizeCurrentDay = ref<string | null>(null);
// Reactive so resizeLiveDurationSeconds recomputes during cross-day resize
const lastResizeClientY = ref(0);
let resizeOriginalEvent: CalendarEvent | null = null;
let resizeOriginalTop = 0;
let resizeOriginalHeight = 0;
let resizeOriginalDayStr = '';
function getGridConstants() {
const s = params.calendarSettings.value;
const startMin = s.startHour * 60;
const endMin = s.endHour * 60;
const snapPx = (s.snapMinutes / s.slotMinutes) * SLOT_HEIGHT;
const totalGridPx = params.minutesToPixels(endMin - startMin);
return { s, startMin, endMin, snapPx, totalGridPx };
}
/**
* Compute the resolved start/end times for the current resize state.
* Used by both resizeLiveDurationSeconds and onResizePointerUp to avoid
* duplicating the 4-way (edge × cross-day) branching logic.
*
* For cross-day resizes, the cursor position (clientY) determines the
* snapped time on the target day. For same-day resizes, the already-snapped
* pixel state (resizeCurrentTop/Height) is used instead.
*/
function computeResizedTimes(clientY: number): { start: Dayjs; end: Dayjs } | null {
if (!resizeOriginalEvent) return null;
const { s } = getGridConstants();
const d = getDayJsInstance();
const isCrossDay =
resizeCurrentDay.value !== null && resizeCurrentDay.value !== resizeOriginalDayStr;
function snappedMinutesFromCursor(): number {
return snapTo(
params.pixelsToMinutesFromMidnight(params.clientYToGridPixels(clientY)),
s.snapMinutes
);
}
if (resizeEdge.value === 'end') {
const start = d(resizeOriginalEvent.timeEntry.start);
const endDay =
isCrossDay && resizeCurrentDay.value
? resizeCurrentDay.value
: resizeOriginalDayStr;
const endMinutes =
isCrossDay && resizeCurrentDay.value
? snappedMinutesFromCursor()
: snapTo(
params.pixelsToMinutesFromMidnight(
resizeCurrentTop.value + resizeCurrentHeight.value
),
s.snapMinutes
);
return { start, end: dayMidnightLocal(endDay).add(endMinutes, 'minute') };
} else {
const end = resizeOriginalEvent.isRunning
? getLocalizedDayJs()
: d(resizeOriginalEvent.timeEntry.end!);
const startDay =
isCrossDay && resizeCurrentDay.value
? resizeCurrentDay.value
: resizeOriginalDayStr;
const startMinutes =
isCrossDay && resizeCurrentDay.value
? snappedMinutesFromCursor()
: snapTo(
params.pixelsToMinutesFromMidnight(resizeCurrentTop.value),
s.snapMinutes
);
return { start: dayMidnightLocal(startDay).add(startMinutes, 'minute'), end };
}
}
function onResizerPointerDown(
e: PointerEvent,
ev: CalendarEvent,
dayEvent: DayEvent,
edge: 'start' | 'end',
dayStr: string
) {
e.preventDefault();
e.stopPropagation();
resizeOriginalEvent = ev;
resizeEdge.value = edge;
resizeOriginalTop = dayEvent.top;
resizeOriginalHeight = dayEvent.height;
resizeEventId.value = ev.id;
isResizing.value = true;
resizeCurrentTop.value = dayEvent.top;
resizeCurrentHeight.value = dayEvent.height;
resizeOriginalDayStr = dayStr;
resizeCurrentDay.value = dayStr;
document.body.classList.add('fc-resizing-active');
document.addEventListener('pointermove', onResizePointerMove);
document.addEventListener('pointerup', onResizePointerUp);
}
function onResizePointerMove(e: PointerEvent) {
if (!resizeOriginalEvent) return;
const { s, snapPx, totalGridPx } = getGridConstants();
const dayStr = params.getDayFromClientX(e.clientX);
if (dayStr) resizeCurrentDay.value = dayStr;
lastResizeClientY.value = e.clientY;
const isCrossDay = resizeCurrentDay.value !== resizeOriginalDayStr;
const cursorSnapped = snapTo(params.clientYToGridPixels(e.clientY), snapPx);
if (resizeEdge.value === 'end') {
if (isCrossDay && resizeCurrentDay.value) {
resizeCurrentHeight.value =
resizeCurrentDay.value > resizeOriginalDayStr
? totalGridPx - resizeOriginalTop
: snapPx;
} else {
const snappedEnd = Math.max(
resizeOriginalTop + snapPx,
Math.min(cursorSnapped, totalGridPx)
);
resizeCurrentHeight.value = snappedEnd - resizeOriginalTop;
}
} else {
let maxTopForRunning = Infinity;
if (resizeOriginalEvent.isRunning) {
const now = getLocalizedDayJs();
const nowMinutes = now.hour() * 60 + now.minute() + now.second() / 60;
maxTopForRunning = params.minutesToPixels(
Math.min(nowMinutes, s.endHour * 60) - s.startHour * 60
);
}
if (isCrossDay && resizeCurrentDay.value) {
if (resizeCurrentDay.value < resizeOriginalDayStr) {
resizeCurrentTop.value = 0;
resizeCurrentHeight.value = resizeOriginalTop + resizeOriginalHeight;
} else if (!resizeOriginalEvent.isRunning) {
const bottom = resizeOriginalTop + resizeOriginalHeight;
resizeCurrentTop.value = bottom - snapPx;
resizeCurrentHeight.value = snapPx;
}
} else {
const bottom = resizeOriginalTop + resizeOriginalHeight;
const upperLimit = Math.min(bottom - snapPx, maxTopForRunning);
const snappedStart = Math.max(0, Math.min(cursorSnapped, upperLimit));
resizeCurrentTop.value = snappedStart;
resizeCurrentHeight.value = bottom - snappedStart;
}
}
}
const resizeCrossDayPreviewsByDay = computed<Record<string, Record<string, string>>>(() => {
if (!isResizing.value || !resizeOriginalEvent) return {};
if (resizeCurrentDay.value === resizeOriginalDayStr) return {};
const { startMin, snapPx, totalGridPx } = getGridConstants();
const snappedY = snapTo(params.clientYToGridPixels(lastResizeClientY.value), snapPx);
const baseStyle = {
position: 'absolute',
left: '0',
right: '0',
backgroundColor: resizeOriginalEvent.backgroundColor,
borderColor: resizeOriginalEvent.borderColor,
opacity: '0.5',
zIndex: '100',
borderRadius: 'calc(var(--radius) - 4px)',
border: '1px solid var(--border)',
};
const result: Record<string, Record<string, string>> = {};
const allDays = params.viewDays.value.map((d) => d.format('YYYY-MM-DD'));
const originIdx = allDays.indexOf(resizeOriginalDayStr);
const cursorIdx = allDays.indexOf(resizeCurrentDay.value!);
if (originIdx < 0 || cursorIdx < 0) return {};
const minIdx = Math.min(originIdx, cursorIdx);
const maxIdx = Math.max(originIdx, cursorIdx);
for (let i = minIdx; i <= maxIdx; i++) {
const dayStr = allDays[i]!;
if (dayStr === resizeOriginalDayStr) continue;
if (dayStr === resizeCurrentDay.value) {
if (resizeEdge.value === 'end') {
const eventStartDay = resizeOriginalEvent.dayStart.format('YYYY-MM-DD');
let previewTop = 0;
if (dayStr === eventStartDay) {
const eventStartMinutes =
resizeOriginalEvent.dayStart.hour() * 60 +
resizeOriginalEvent.dayStart.minute();
previewTop = params.minutesToPixels(
Math.max(0, eventStartMinutes - startMin)
);
}
const clampedY = Math.max(previewTop + snapPx, Math.min(snappedY, totalGridPx));
result[dayStr] = {
...baseStyle,
top: `${previewTop}px`,
height: `${clampedY - previewTop}px`,
};
} else {
const eventEndDay = resizeOriginalEvent.dayEnd.format('YYYY-MM-DD');
let previewBottom = totalGridPx;
if (dayStr === eventEndDay) {
const eventEndMinutes =
resizeOriginalEvent.dayEnd.hour() * 60 +
resizeOriginalEvent.dayEnd.minute();
previewBottom = params.minutesToPixels(
Math.max(0, eventEndMinutes - startMin)
);
}
const clampedY = Math.max(0, Math.min(snappedY, previewBottom - snapPx));
result[dayStr] = {
...baseStyle,
top: `${clampedY}px`,
height: `${previewBottom - clampedY}px`,
};
}
} else {
result[dayStr] = { ...baseStyle, top: '0px', height: `${totalGridPx}px` };
}
}
return result;
});
const resizeLiveDurationSeconds = computed<number | null>(() => {
if (!isResizing.value || !resizeOriginalEvent) return null;
const times = computeResizedTimes(lastResizeClientY.value);
if (!times) return null;
const diff = times.end.diff(times.start, 'second');
return diff > 0 ? diff : 0;
});
function getResizeOriginalDayStr(): string {
return resizeOriginalDayStr;
}
function resetResizeState() {
resizeEventId.value = null;
resizeCurrentDay.value = null;
}
async function onResizePointerUp(e: PointerEvent) {
document.removeEventListener('pointermove', onResizePointerMove);
document.removeEventListener('pointerup', onResizePointerUp);
document.body.classList.remove('fc-resizing-active');
const times = computeResizedTimes(e.clientY);
isResizing.value = false;
if (!resizeOriginalEvent || !times) {
resetResizeState();
return;
}
const timeEntry = resizeOriginalEvent.timeEntry;
const isRunning = resizeOriginalEvent.isRunning;
const updatedTimeEntry = {
...timeEntry,
start: resizeEdge.value === 'start' ? times.start.utc().format() : timeEntry.start,
end:
resizeEdge.value === 'end'
? times.end.utc().format()
: isRunning
? null
: timeEntry.end,
} as TimeEntry;
const d = getDayJsInstance();
// Prevent end before start
if (
updatedTimeEntry.end !== null &&
!d(updatedTimeEntry.end).isAfter(d(updatedTimeEntry.start))
) {
resetResizeState();
return;
}
// Prevent start in the future for running entries
if (updatedTimeEntry.end === null && d(updatedTimeEntry.start).isAfter(d())) {
resetResizeState();
return;
}
resetResizeState();
params.optimisticOverrides.value = new Map(params.optimisticOverrides.value).set(
updatedTimeEntry.id,
updatedTimeEntry
);
try {
await params.updateTimeEntry(updatedTimeEntry);
} catch {
const reverted = new Map(params.optimisticOverrides.value);
reverted.delete(updatedTimeEntry.id);
params.optimisticOverrides.value = reverted;
}
params.emitRefresh();
}
onUnmounted(() => {
document.removeEventListener('pointermove', onResizePointerMove);
document.removeEventListener('pointerup', onResizePointerUp);
document.body.classList.remove('fc-resizing-active');
});
return {
isResizing,
resizeEventId,
resizeEdge,
resizeCurrentTop,
resizeCurrentHeight,
resizeCurrentDay,
resizeCrossDayPreviewsByDay,
resizeLiveDurationSeconds,
getResizeOriginalDayStr,
onResizerPointerDown,
};
}

View File

@@ -0,0 +1,205 @@
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs';
import { getDayJsInstance } from '../utils/time';
import { getUserTimezone } from '../utils/settings';
import type { CalendarSettings } from './calendarSettings';
import { SLOT_HEIGHT } from './calendarTypes';
export function useSlotSelection(params: {
calendarSettings: Ref<CalendarSettings>;
viewDays: ComputedRef<Dayjs[]>;
totalGridHeight: ComputedRef<number>;
pixelsToMinutesFromMidnight: (px: number) => number;
getDayFromClientX: (clientX: number) => string | null;
clientYToGridPixels: (clientY: number) => number;
onSelectionComplete: (start: Dayjs, end: Dayjs) => void;
}) {
const isSelecting = ref(false);
const selectionDay = ref<string | null>(null);
const selectionTop = ref(0);
const selectionHeight = ref(0);
const selectionEndDay = ref<string | null>(null);
const selectionEndTop = ref(0);
const selectionEndHeight = ref(0);
// Non-reactive state
let selectionStartGridY = 0;
let selectionStartDay = '';
function onSlotPointerDown(e: PointerEvent) {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('.fc-event') || target.closest('.activity-status-box')) return;
const dateStr = params.getDayFromClientX(e.clientX);
if (!dateStr) return;
e.preventDefault();
const gridY = params.clientYToGridPixels(e.clientY);
const s = params.calendarSettings.value;
const snapPx = (s.snapMinutes / s.slotMinutes) * SLOT_HEIGHT;
const snappedY = Math.floor(gridY / snapPx) * snapPx;
selectionStartGridY = snappedY;
selectionStartDay = dateStr;
selectionDay.value = dateStr;
selectionTop.value = snappedY;
selectionHeight.value = snapPx;
selectionEndDay.value = null;
selectionEndTop.value = 0;
selectionEndHeight.value = 0;
isSelecting.value = true;
document.addEventListener('pointermove', onSelectionPointerMove);
document.addEventListener('pointerup', onSelectionPointerUp);
}
function onSelectionPointerMove(e: PointerEvent) {
if (!isSelecting.value) return;
const gridY = params.clientYToGridPixels(e.clientY);
const s = params.calendarSettings.value;
const snapPx = (s.snapMinutes / s.slotMinutes) * SLOT_HEIGHT;
const maxPx = params.totalGridHeight.value;
const currentDay = params.getDayFromClientX(e.clientX);
if (currentDay && currentDay !== selectionStartDay) {
selectionTop.value = selectionStartGridY;
selectionHeight.value = maxPx - selectionStartGridY;
selectionEndDay.value = currentDay;
const snappedEnd = Math.ceil(gridY / snapPx) * snapPx;
selectionEndTop.value = 0;
selectionEndHeight.value = Math.max(snapPx, Math.min(snappedEnd, maxPx));
} else {
selectionEndDay.value = null;
if (gridY >= selectionStartGridY) {
const snappedEnd = Math.ceil(gridY / snapPx) * snapPx;
selectionTop.value = selectionStartGridY;
selectionHeight.value = Math.max(
snapPx,
Math.min(snappedEnd - selectionStartGridY, maxPx - selectionStartGridY)
);
} else {
const snappedStart = Math.floor(gridY / snapPx) * snapPx;
selectionTop.value = Math.max(0, snappedStart);
selectionHeight.value = Math.max(
snapPx,
selectionStartGridY + snapPx - selectionTop.value
);
}
}
}
function onSelectionPointerUp() {
document.removeEventListener('pointermove', onSelectionPointerMove);
document.removeEventListener('pointerup', onSelectionPointerUp);
if (!isSelecting.value) return;
isSelecting.value = false;
const s = params.calendarSettings.value;
const snap = s.snapMinutes;
const dayjs = getDayJsInstance();
const startMinutes = params.pixelsToMinutesFromMidnight(selectionTop.value);
const snappedStartMin = Math.floor(startMinutes / snap) * snap;
let startLocal;
let endLocal;
if (selectionEndDay.value && selectionEndDay.value !== selectionStartDay) {
const endMinutes = params.pixelsToMinutesFromMidnight(
selectionEndTop.value + selectionEndHeight.value
);
let snappedEndMin = Math.ceil(endMinutes / snap) * snap;
if (snappedEndMin <= 0) snappedEndMin = snap;
let startDateStr = selectionStartDay;
let endDateStr = selectionEndDay.value;
let startMin = snappedStartMin;
let endMin = snappedEndMin;
// Normalize: ensure start day is before end day (handle right-to-left selection)
if (endDateStr < startDateStr) {
[startDateStr, endDateStr] = [endDateStr, startDateStr];
// Cursor position on earlier day = bottom of end-day box
startMin =
Math.floor(
params.pixelsToMinutesFromMidnight(
selectionEndTop.value + selectionEndHeight.value
) / snap
) * snap;
// Click position on later day = top of start-day box
endMin =
Math.ceil(params.pixelsToMinutesFromMidnight(selectionTop.value) / snap) * snap;
if (endMin <= 0) endMin = snap;
}
startLocal = dayjs(`${startDateStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(startMin, 'minute');
endLocal = dayjs(`${endDateStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(endMin, 'minute');
} else {
const startDateStr = selectionStartDay;
const endMinutes = params.pixelsToMinutesFromMidnight(
selectionTop.value + selectionHeight.value
);
let snappedEndMin = Math.ceil(endMinutes / snap) * snap;
if (snappedEndMin <= snappedStartMin) {
snappedEndMin = snappedStartMin + snap;
}
startLocal = dayjs(`${startDateStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(snappedStartMin, 'minute');
endLocal = dayjs(`${startDateStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(snappedEndMin, 'minute');
}
params.onSelectionComplete(startLocal.utc(), endLocal.utc());
}
// Intermediate days between start and end day (excluding both) that need full-height selection
const selectionIntermediateDays = computed<Set<string>>(() => {
if (!selectionDay.value || !selectionEndDay.value) return new Set();
const allDays = params.viewDays.value.map((d) => d.format('YYYY-MM-DD'));
const startIdx = allDays.indexOf(selectionDay.value);
const endIdx = allDays.indexOf(selectionEndDay.value);
if (startIdx < 0 || endIdx < 0) return new Set();
const minIdx = Math.min(startIdx, endIdx);
const maxIdx = Math.max(startIdx, endIdx);
const days = new Set<string>();
for (let i = minIdx + 1; i < maxIdx; i++) {
days.add(allDays[i]!);
}
return days;
});
function clearSelection() {
selectionDay.value = null;
selectionEndDay.value = null;
}
onUnmounted(() => {
document.removeEventListener('pointermove', onSelectionPointerMove);
document.removeEventListener('pointerup', onSelectionPointerUp);
});
return {
isSelecting,
selectionDay,
selectionTop,
selectionHeight,
selectionEndDay,
selectionEndTop,
selectionEndHeight,
selectionIntermediateDays,
onSlotPointerDown,
clearSelection,
};
}

View File

@@ -1,189 +0,0 @@
import { onActivated, onDeactivated, onMounted, onUnmounted, type Ref } from 'vue';
import type FullCalendar from '@fullcalendar/vue3';
interface VisualSnapOptions {
calendarRef: Ref<InstanceType<typeof FullCalendar> | null>;
snapMinutes: () => number;
slotMinutes: () => number;
formatDuration: (durationSeconds: number) => string;
}
export function useVisualSnap({
calendarRef,
snapMinutes,
slotMinutes,
formatDuration,
}: VisualSnapOptions) {
let rafId: number | null = null;
function getCalendarEl(): HTMLElement | null {
return (calendarRef.value?.$el as HTMLElement) ?? null;
}
function getSnapPixels(): number {
const calendarEl = getCalendarEl();
if (!calendarEl) return 25;
const slot = calendarEl.querySelector('.fc-timegrid-slot-lane') as HTMLElement;
if (!slot) return 25;
const slotHeightPx = slot.getBoundingClientRect().height;
return (snapMinutes() / slotMinutes()) * slotHeightPx;
}
function findMirrorHarness(calendarEl: HTMLElement) {
const mirror = calendarEl.querySelector('.fc-event-mirror') as HTMLElement | null;
const harness = mirror?.closest('.fc-timegrid-event-harness') as HTMLElement | null;
if (harness) {
harness.style.pointerEvents = 'none';
}
return { mirror, harness };
}
function updateMirrorDurationLabel(
mirror: HTMLElement,
snappedTop: number,
snappedEnd: number,
snapPx: number
) {
const snappedDurationMin = Math.round((snappedEnd - snappedTop) / snapPx) * snapMinutes();
const durationText = formatDuration(snappedDurationMin * 60);
const durationEl = mirror.querySelector('.fc-event-main')?.querySelector('div:last-child');
if (durationEl) {
durationEl.textContent = durationText;
}
}
function startLoop(onFrame: (calendarEl: HTMLElement, snapPx: number) => void) {
const calendarEl = getCalendarEl();
if (!calendarEl) return;
const snapPx = getSnapPixels();
if (snapPx <= 0) return;
const loop = () => {
onFrame(calendarEl, snapPx);
rafId = requestAnimationFrame(loop);
};
rafId = requestAnimationFrame(loop);
}
function stop() {
document.body.classList.remove('fc-resizing-active');
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
// --- Public snap starters ---
function startSelectSnap() {
// Don't start if another snap loop is already running
if (rafId !== null) return;
startLoop((calendarEl, snapPx) => {
const { mirror, harness } = findMirrorHarness(calendarEl);
if (!harness || !mirror) return;
const top = parseFloat(harness.style.top) || 0;
const endPos = -(parseFloat(harness.style.bottom) || 0);
const snappedTop = Math.floor(top / snapPx) * snapPx;
const snappedEnd = Math.ceil(endPos / snapPx) * snapPx;
const clampedEnd = Math.max(snappedTop + snapPx, snappedEnd);
harness.style.top = snappedTop + 'px';
harness.style.bottom = -clampedEnd + 'px';
updateMirrorDurationLabel(mirror, snappedTop, clampedEnd, snapPx);
});
}
function startDragSnap() {
stop();
startLoop((calendarEl, snapPx) => {
const { harness } = findMirrorHarness(calendarEl);
if (!harness) return;
const top = parseFloat(harness.style.top) || 0;
const endPos = -(parseFloat(harness.style.bottom) || 0);
const height = endPos - top;
const snappedTop = Math.floor(top / snapPx) * snapPx;
harness.style.top = snappedTop + 'px';
harness.style.bottom = -(snappedTop + height) + 'px';
});
}
function startResizeSnap() {
stop();
document.body.classList.add('fc-resizing-active');
let initialTop: number | null = null;
let initialEnd: number | null = null;
let resizeEdge: 'top' | 'bottom' | null = null;
startLoop((calendarEl, snapPx) => {
const { mirror, harness } = findMirrorHarness(calendarEl);
if (!harness) return;
const top = parseFloat(harness.style.top) || 0;
const endPos = -(parseFloat(harness.style.bottom) || 0);
// Detect which edge is being resized
if (initialTop === null) {
initialTop = top;
initialEnd = endPos;
} else if (resizeEdge === null) {
const topDelta = Math.abs(top - initialTop);
const endDelta = Math.abs(endPos - initialEnd!);
if (topDelta > 0.5) {
resizeEdge = 'top';
} else if (endDelta > 0.5) {
resizeEdge = 'bottom';
}
}
if (resizeEdge === 'bottom') {
const snappedEnd = Math.ceil(endPos / snapPx) * snapPx;
const clampedEnd = Math.max(top + snapPx, snappedEnd);
harness.style.bottom = -clampedEnd + 'px';
if (mirror) updateMirrorDurationLabel(mirror, top, clampedEnd, snapPx);
} else if (resizeEdge === 'top') {
const snappedTop = Math.floor(top / snapPx) * snapPx;
const clampedTop = Math.min(endPos - snapPx, snappedTop);
harness.style.top = clampedTop + 'px';
if (mirror) updateMirrorDurationLabel(mirror, clampedTop, endPos, snapPx);
}
});
}
// Pointerdown handler for starting select snap on timegrid background
function handleTimegridPointerDown(e: PointerEvent) {
const target = e.target as HTMLElement;
if (target.closest('.fc-event')) return;
startSelectSnap();
}
// Lifecycle: attach/detach pointerdown listener
function attachListener() {
const calendarEl = getCalendarEl();
calendarEl?.addEventListener('pointerdown', handleTimegridPointerDown);
}
function detachListener() {
const calendarEl = getCalendarEl();
calendarEl?.removeEventListener('pointerdown', handleTimegridPointerDown);
}
onMounted(attachListener);
onActivated(attachListener);
onDeactivated(() => {
stop();
detachListener();
});
onUnmounted(() => {
stop();
detachListener();
});
return {
startSelectSnap,
startDragSnap,
startResizeSnap,
stop,
};
}

View File

@@ -25,7 +25,7 @@ const expandedStatusClasses = computed(() => {
<button
:class="
twMerge(
'font-medium rounded flex items-center transition justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent',
'font-medium text-base rounded flex items-center transition justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent',
expandedStatusClasses,
props.size
)

View File

@@ -7,7 +7,7 @@ import {
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
} from '@/Components/ui/number-field';
} from '..';
const props = defineProps<{
name: string;

View File

@@ -7,15 +7,17 @@ import {
type WeekStartDay,
} from '@/packages/ui/src/utils/time';
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src/popover';
import { Calendar } from '@/Components/ui/calendar';
import { Calendar } from '..';
import { Button } from '@/packages/ui/src/Buttons';
import { CalendarIcon } from 'lucide-vue-next';
import { CalendarIcon, XIcon } from 'lucide-vue-next';
import { parseDate, type DateValue } from '@internationalized/date';
import type { Organization } from '@/packages/api/src';
const props = defineProps<{
tabindex?: string;
class?: string;
size?: 'sm' | 'default';
clearable?: boolean;
}>();
// This has to be a localized timestamp, not UTC
@@ -59,6 +61,12 @@ function handleDateSelect(newDate: DateValue | undefined) {
emit('changed', newValue);
open.value = false;
}
function handleClear(event: Event) {
event.stopPropagation();
model.value = null;
emit('changed', null);
}
</script>
<template>
@@ -67,11 +75,18 @@ function handleDateSelect(newDate: DateValue | undefined) {
<PopoverTrigger as-child>
<Button
variant="input"
size="sm"
:size="props.size ?? 'default'"
:tabindex="tabindex"
:class="['w-full px-2 gap-1.5', props.class]">
<CalendarIcon class="!size-3 text-muted-foreground" />
<span>{{ displayDate || 'Pick a date' }}</span>
<span :class="{ 'flex-1': clearable }">{{ displayDate || 'Pick a date' }}</span>
<span
v-if="clearable && model"
role="button"
class="hover:bg-muted rounded p-0.5 transition-colors"
@click.stop="handleClear($event)">
<XIcon class="size-3" />
</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="center">

View File

@@ -58,13 +58,14 @@ const modelValue = computed<CalendarDateRange>({
}),
set: (newValue) => {
if (newValue.start) {
console.log(newValue.start);
const date = newValue.start.toDate(getUserTimezone());
emit('update:start', getLocalizedDayJs(date.toString()).format());
}
if (newValue.end) {
const date = newValue.end.toDate(getUserTimezone());
emit('update:end', getLocalizedDayJs(date.toString()).format());
} else {
emit('update:end', '');
}
},
});

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import parse from 'parse-duration';
import { onMounted, ref, watch, inject } from 'vue';
import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
import {
formatHumanReadableDuration,
getDayJsInstance,
parseTimeInput,
} from '@/packages/ui/src/utils/time';
import dayjs from 'dayjs';
import { twMerge } from 'tailwind-merge';
import { TextInput } from '@/packages/ui/src';
@@ -20,51 +23,23 @@ const end = defineModel('end', {
const organization = inject<ComputedRef<Organization>>('organization');
function isHHMM(value: string): boolean {
return HHMMtimeRegex.test(value);
}
function parseHHMM(value: string): string[] | null {
return value.match(HHMMtimeRegex);
}
function updateDuration() {
const time = parse(temporaryCustomTimerEntry.value, 's');
if (isNumeric(temporaryCustomTimerEntry.value)) {
const newStartDate = getDayJsInstance()(end.value).subtract(
parseInt(temporaryCustomTimerEntry.value),
'm'
);
start.value = newStartDate.utc().format();
} else if (isHHMM(temporaryCustomTimerEntry.value)) {
const results = parseHHMM(temporaryCustomTimerEntry.value);
if (results) {
const newStartDate = getDayJsInstance()(end.value)
.subtract(parseInt(results[1]!), 'h')
.subtract(parseInt(results[2]!), 'm');
start.value = newStartDate.utc().format();
}
}
// try to parse natural language like "1h 30m"
else if (time && time > 1) {
const newStartDate = getDayJsInstance()(end.value).subtract(time, 's');
const seconds = parseTimeInput(
temporaryCustomTimerEntry.value,
organization?.value?.number_format,
organization?.value?.interval_format === 'decimal' ? 'hours' : 'minutes'
);
if (seconds && seconds > 0) {
const newStartDate = getDayJsInstance()(end.value).subtract(seconds, 's');
start.value = newStartDate.utc().format();
}
// fallback to minutes if just a number is given
updateTimeEntryInputValue();
}
function isNumeric(value: string) {
return /^-?\d+$/.test(value);
}
const props = defineProps<{
class?: string;
}>();
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
watch([start, end], updateTimeEntryInputValue);
onMounted(() => updateTimeEntryInputValue());

View File

@@ -26,8 +26,7 @@ function updateDuration() {
return;
}
// Use parseTimeInput with 'hours' as default unit for estimated time
const seconds = parseTimeInput(input, 'hours');
const seconds = parseTimeInput(input, organization?.value?.number_format, 'hours');
if (seconds !== null && seconds > 0) {
model.value = seconds;
}

View File

@@ -73,6 +73,7 @@ const dropdownContent = ref();
<DatePicker
v-model="tempStart"
class="w-full"
size="sm"
@changed="updateTimeEntry"></DatePicker>
</div>
</div>
@@ -89,6 +90,7 @@ const dropdownContent = ref();
<DatePicker
v-model="tempEnd"
class="w-full"
size="sm"
@changed="updateTimeEntry"></DatePicker>
</div>
<div v-else-if="end === null && !showEndTimePicker">
@@ -102,7 +104,7 @@ const dropdownContent = ref();
class="w-full"
data-testid="time_entry_range_end"
@keydown.enter.prevent.stop="confirmEndTime"></TimePickerSimple>
<DatePicker v-model="tempEnd" class="w-full"></DatePicker>
<DatePicker v-model="tempEnd" class="w-full" size="sm"></DatePicker>
<Button variant="outline" size="sm" class="w-full" @click="confirmEndTime">
Confirm
</Button>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Dialog, DialogContent, DialogFooter } from '@/Components/ui/dialog';
import { Dialog, DialogContent, DialogFooter } from './dialog/index';
import { computed } from 'vue';
const props = defineProps({

View File

@@ -1,11 +1,5 @@
<script setup lang="ts">
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import type { BillableKey } from '@/types/projects';
const model = defineModel<BillableKey>({

View File

@@ -26,6 +26,7 @@ const props = defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string;
enableEstimatedTime: boolean;
organizationBillableRate: number | null;
}>();
const activeClients = computed(() => {
@@ -113,7 +114,10 @@ const currentClientName = computed(() => {
<ProjectEditBillableSection
v-model:is-billable="project.is_billable"
v-model:billable-rate="project.billable_rate"
:currency="currency"></ProjectEditBillableSection>
:currency="currency"
:organization-billable-rate="
organizationBillableRate
"></ProjectEditBillableSection>
<EstimatedTimeSection
v-if="enableEstimatedTime"
v-model="project.estimated_time"

View File

@@ -1,13 +1,7 @@
<script setup lang="ts">
import { Field, FieldDescription, FieldLabel } from '../field';
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import {
Tooltip,
TooltipContent,
@@ -16,15 +10,12 @@ import {
} from '@/packages/ui/src/tooltip';
import { computed, onMounted, ref, watch } from 'vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
defineProps<{
const props = defineProps<{
currency: string;
organizationBillableRate: number | null;
}>();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
type RateType = 'default-rate' | 'custom-rate';
const billableDefault = ref<'billable' | 'non-billable'>('non-billable');
@@ -55,7 +46,7 @@ watch(rateType, () => {
billableDefault.value = 'billable';
isBillable.value = true;
if (!billableRate.value) {
billableRate.value = organization.value?.billable_rate ?? null;
billableRate.value = props.organizationBillableRate ?? null;
}
}
});
@@ -63,7 +54,7 @@ watch(rateType, () => {
const displayedRate = computed({
get() {
if (rateType.value === 'default-rate') {
return organization.value?.billable_rate ?? null;
return props.organizationBillableRate ?? null;
}
return billableRate.value;
},

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Tabs, TabsList } from '@/Components/ui/tabs';
import { Tabs, TabsList } from '../tabs';
defineProps<{
defaultValue?: string;

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { TabsTrigger } from '@/Components/ui/tabs';
import { TabsTrigger } from '../tabs';
import { twMerge } from 'tailwind-merge';
import type { Component } from 'vue';

Some files were not shown because too many files have changed in this diff Show More