Compare commits

..

45 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
Gregor Vostrak
f582adab0d fix time entries incorrectly not updating in calendar
the synced snapDuration cause incorrect noops on updates f.e. 15:55-16:00 on a 15 minute snap
2026-02-24 19:38:55 +01:00
Gregor Vostrak
c60cff04ce fix calendar flickering on move for non-aligned entries
this is a trade-off where for non grid aligned entries, the cursor position is a bit off, but data and visual are stil in sync. otherwise fc overrides height on drag, causing flickers.
2026-02-24 15:30:18 +01:00
Gregor Vostrak
cae41e4b4f improve visual snapping boundaries 2026-02-24 14:02:18 +01:00
Gregor Vostrak
8973be9dab filament minor version update 2026-02-24 13:43:21 +01:00
Gregor Vostrak
2a0b8d31e6 add calendar settings + custom visual snapping 2026-02-24 12:41:15 +01:00
222 changed files with 10274 additions and 3400 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. 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. 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. 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 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, // 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. // 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\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
use Spatie\TemporaryDirectory\TemporaryDirectory; use Spatie\TemporaryDirectory\TemporaryDirectory;
@@ -246,7 +247,7 @@ class TimeEntryController extends Controller
'user', 'user',
'tagsRelation', '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'; $folderPath = 'exports';
$path = $folderPath.'/'.$filename; $path = $folderPath.'/'.$filename;
$localizationService = LocalizationService::forOrganization($organization); $localizationService = LocalizationService::forOrganization($organization);
@@ -469,7 +470,7 @@ class TimeEntryController extends Controller
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
$localizationService = LocalizationService::forOrganization($organization); $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'; $folderPath = 'exports';
$path = $folderPath.'/'.$filename; $path = $folderPath.'/'.$filename;

2066
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 { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures'; import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test'; import { createBareTimeEntryViaApi, createTimeEntryWithTimestampsViaApi } from './utils/api';
import type { Page } from '@playwright/test';
async function goToCalendar(page: Page) { async function goToCalendar(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); 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) { async function openSettingsPopover(page: Page) {
@@ -17,6 +18,29 @@ async function clearCalendarSettings(page: Page) {
await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings')); 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.describe('Calendar Settings', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page); await clearCalendarSettings(page);
@@ -39,7 +63,9 @@ test.describe('Calendar Settings', () => {
// Change snap interval to 30 min // Change snap interval to 30 min
await page.getByLabel('Snap Interval').click(); await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '30 min' }).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 // Verify localStorage was updated
const stored = await page.evaluate(() => const stored = await page.evaluate(() =>
@@ -54,7 +80,7 @@ test.describe('Calendar Settings', () => {
await expect(page.getByLabel('Snap Interval')).toContainText('30 min'); 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, page,
}) => { }) => {
await goToCalendar(page); await goToCalendar(page);
@@ -71,24 +97,23 @@ test.describe('Calendar Settings', () => {
// Change start time to 8 AM (valid) // Change start time to 8 AM (valid)
await page.getByLabel('Start Time').click(); await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).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 // Try to set start time to 6 PM (invalid: equals end time) — should be rejected
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);
await page.getByLabel('Start Time').click(); await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click(); await page.getByRole('option', { name: '6:00 PM' }).click();
// Should be rejected — start time stays at 8 AM // Should be rejected — start time stays at 8 AM
await expect(page.getByLabel('Start Time')).toContainText('8:00 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 ({ test('end time change is applied to calendar and rejects invalid values', async ({ page }) => {
page,
}) => {
await goToCalendar(page); await goToCalendar(page);
// Verify 19:00 slot exists with default end (24:00) // 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) // Change end time to 6 PM (valid)
await page.getByLabel('End Time').click(); await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '6:00 PM' }).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 // Try to set end time to 8 AM (invalid: equals start time) — should be rejected
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);
await page.getByLabel('End Time').click(); await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click(); await page.getByRole('option', { name: '8:00 AM' }).click();
// Should be rejected — end time stays at 6 PM // Should be rejected — end time stays at 6 PM
await expect(page.getByLabel('End Time')).toContainText('6:00 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 }) => { test('grid scale affects number of calendar slots', async ({ page }) => {
@@ -128,19 +154,31 @@ test.describe('Calendar Settings', () => {
await openSettingsPopover(page); await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click(); await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).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(); 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 openSettingsPopover(page);
await page.getByLabel('Grid Scale').click(); await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '5 min', exact: true }).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(); // Wait for FullCalendar to re-render with new slot count
expect(smallerSlotCount).toBeGreaterThan(defaultSlotCount); 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 }) => { 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.getByRole('option', { name: '10:00 PM' }).click();
await page.getByLabel('Grid Scale').click(); await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).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 // Navigate away and back
await page.goto(PLAYWRIGHT_BASE_URL + '/time'); await page.goto(PLAYWRIGHT_BASE_URL + '/time');
@@ -170,3 +210,480 @@ test.describe('Calendar Settings', () => {
await expect(page.getByLabel('Grid Scale')).toContainText('30 min'); 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); 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 // 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(); 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 // 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); 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 // Employee Permission Tests
// ============================================= // =============================================

View File

@@ -1,6 +1,10 @@
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures'; import { test } from '../playwright/fixtures';
dayjs.extend(utc);
import { import {
createProjectViaApi, createProjectViaApi,
createClientViaApi, createClientViaApi,
@@ -11,6 +15,7 @@ import {
createBillableProjectViaApi, createBillableProjectViaApi,
createTimeEntryWithBillableStatusViaApi, createTimeEntryWithBillableStatusViaApi,
createTagViaApi, createTagViaApi,
createReportViaApi,
} from './utils/api'; } from './utils/api';
import { import {
goToReporting, goToReporting,
@@ -766,6 +771,97 @@ test('test that updating expiration date on already-public report works', async
expect(returnedDate.getTime()).toBeGreaterThan(now.getTime()); 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 // 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); 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 // Sorting Tests
// ============================================= // =============================================

View File

@@ -15,6 +15,7 @@ import {
createBareTimeEntryViaApi, createBareTimeEntryViaApi,
createTimeEntryViaApi, createTimeEntryViaApi,
updateOrganizationCurrencyViaWeb, updateOrganizationCurrencyViaWeb,
updateOrganizationSettingViaApi,
} from './utils/api'; } from './utils/api';
// Date picker button name patterns for different date formats // 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, page,
ctx, ctx,
}) => { }) => {
await updateOrganizationCurrencyViaWeb(ctx, 'USD'); await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');
await goToTimeOverview(page); await goToTimeOverview(page);
await createEmptyTimeEntry(page); await createEmptyTimeEntry(page);
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first(); 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, page,
ctx, ctx,
}) => { }) => {
await updateOrganizationCurrencyViaWeb(ctx, 'EUR'); await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');
await goToTimeOverview(page); await goToTimeOverview(page);
await createEmptyTimeEntry(page); await createEmptyTimeEntry(page);
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first(); 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); 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); await goToTimeOverview(page);
// Open the create modal // Open the create modal
@@ -978,7 +984,6 @@ test('test that decimal duration input works in create modal', async ({ page })
.fill('Decimal duration test'); .fill('Decimal duration test');
// Test decimal duration input "1.5h" (should be interpreted as 1.5 hours = 90 minutes) // 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"]'); const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
await durationInput.fill('1.5h'); await durationInput.fill('1.5h');
await durationInput.press('Tab'); 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); 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 }) => { test('test that project selection works in create modal', async ({ page, ctx }) => {
const projectName = 'Create Modal Project ' + Math.floor(1 + Math.random() * 10000); const projectName = 'Create Modal Project ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: projectName }); await createProjectViaApi(ctx, { name: projectName });
@@ -1535,3 +2042,228 @@ test.describe('Employee Time Entry Isolation', () => {
await expect(timeEntryRow).not.toBeVisible(); 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 }) => { 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 goToDashboard(page);
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first(); 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 }) => { 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 goToDashboard(page);
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first(); const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,24 @@
import type { Client } from '@/packages/api/src'; import type { Client } from '@/packages/api/src';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline'; 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 { useClientsStore } from '@/utils/useClients';
import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue'; import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
import TableRow from '@/Components/TableRow.vue'; import TableRow from '@/Components/TableRow.vue';
import ClientEditModal from '@/Components/Common/Client/ClientEditModal.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(); const { projects } = useProjectsQuery();
@@ -33,38 +46,63 @@ const showEditModal = ref(false);
</script> </script>
<template> <template>
<TableRow> <ContextMenu>
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal> <ContextMenuTrigger as-child>
<div <TableRow>
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"> <ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
<span> <div
{{ client.name }} 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> <span>
</div> {{ client.name }}
<div </span>
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary"> </div>
<span class="text-text-secondary"> {{ projectCount }} Projects </span> <div
</div> class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
<div <span class="text-text-secondary"> {{ projectCount }} Projects </span>
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium"> </div>
<template v-if="client.is_archived"> <div
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<span>Archived</span> <template v-if="client.is_archived">
</template> <ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<template v-else> <span>Archived</span>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon> </template>
<span>Active</span> <template v-else>
</template> <CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
</div> <span>Active</span>
<div </template>
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"> </div>
<ClientMoreOptionsDropdown <div
:client="client" 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">
@edit="showEditModal = true" <ClientMoreOptionsDropdown
@archive="archiveClient" :client="client"
@delete="deleteClient"></ClientMoreOptionsDropdown> @edit="showEditModal = true"
</div> @archive="archiveClient"
</TableRow> @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> </template>
<style scoped></style> <style scoped></style>

View File

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

View File

@@ -1,11 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import type { BillableKey } from '@/types/projects'; import type { BillableKey } from '@/types/projects';
const model = defineModel<BillableKey>({ 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 { type MemberBillableKey, useMembersStore } from '@/utils/useMembers';
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue'; import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field'; import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,

View File

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

View File

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

View File

@@ -2,12 +2,24 @@
import type { Member, Organization } from '@/packages/api/src'; import type { Member, Organization } from '@/packages/api/src';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline'; 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 MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
import TableRow from '@/Components/TableRow.vue'; import TableRow from '@/Components/TableRow.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification'; 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 { computed, type ComputedRef, inject, ref } from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue'; import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.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 MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
import { capitalizeFirstLetter } from '../../../utils/format'; import { capitalizeFirstLetter } from '../../../utils/format';
import { formatCents } from '../../../packages/ui/src/utils/money'; import { formatCents } from '../../../packages/ui/src/utils/money';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/packages/ui/src';
const props = defineProps<{ const props = defineProps<{
member: Member; member: Member;
@@ -55,73 +74,112 @@ const userHasValidMailAddress = computed(() => {
</script> </script>
<template> <template>
<TableRow> <ContextMenu>
<div <ContextMenuTrigger as-child>
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"> <TableRow>
<span> <div
{{ member.name }} 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> <span>
</div> {{ member.name }}
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> </span>
{{ member.email }} </div>
</div> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> {{ member.email }}
{{ capitalizeFirstLetter(member.role) }} </div>
</div> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> {{ capitalizeFirstLetter(member.role) }}
{{ </div>
member.billable_rate <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
? formatCents( {{
member.billable_rate, member.billable_rate
organization?.currency, ? formatCents(
organization?.currency_format, member.billable_rate,
organization?.currency_symbol, organization?.currency,
organization?.number_format 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"> </div>
<template v-if="member.is_placeholder === false"> <div
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<span>Active</span> <template v-if="member.is_placeholder === false">
</template> <CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<template v-else> <span>Active</span>
<UserCircleIcon class="w-4 text-icon-default"></UserCircleIcon> </template>
<span>Inactive</span> <template v-else>
</template> <UserCircleIcon class="w-4 text-icon-default"></UserCircleIcon>
</div> <span>Inactive</span>
<div </template>
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"> </div>
<SecondaryButton <div
v-if=" 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">
member.is_placeholder === true && <SecondaryButton
canInvitePlaceholderMembers() && v-if="
userHasValidMailAddress member.is_placeholder === true &&
" canInvitePlaceholderMembers() &&
size="small" userHasValidMailAddress
@click="invitePlaceholder(member.id)" "
>Invite size="small"
</SecondaryButton> @click="invitePlaceholder(member.id)"
<MemberMoreOptionsDropdown >Invite
:member="member" </SecondaryButton>
@edit="showEditMemberModal = true" <MemberMoreOptionsDropdown
@delete="removeMember" :member="member"
@merge="showMergeMemberModal = true" @edit="showEditMemberModal = true"
@make-placeholder=" @delete="removeMember"
showMakeMemberPlaceholderModal = true @merge="showMergeMemberModal = true"
"></MemberMoreOptionsDropdown> @make-placeholder="
</div> showMakeMemberPlaceholderModal = true
<MemberEditModal v-model:show="showEditMemberModal" :member="member"></MemberEditModal> "></MemberMoreOptionsDropdown>
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal> </div>
<MemberMakePlaceholderModal <MemberEditModal
v-model:show="showMakeMemberPlaceholderModal" v-model:show="showEditMemberModal"
:member="member"></MemberMakePlaceholderModal> :member="member"></MemberEditModal>
<MemberDeleteModal <MemberMergeModal
v-model:show="showDeleteMemberModal" v-model:show="showMergeMemberModal"
:member="member"></MemberDeleteModal> :member="member"></MemberMergeModal>
</TableRow> <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> </template>
<style scoped></style> <style scoped></style>

View File

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

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { UserGroupIcon } from '@heroicons/vue/16/solid'; 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 BaseFilterBadge from './BaseFilterBadge.vue';
import type { Client } from '@/packages/api/src'; import type { Client } from '@/packages/api/src';
import { NO_CLIENT_ID } from './constants'; import { NO_CLIENT_ID } from './constants';

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { CircleStackIcon } from '@heroicons/vue/16/solid'; 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'; import BaseFilterBadge from './BaseFilterBadge.vue';
type StatusValue = 'active' | 'archived' | 'all'; type StatusValue = 'active' | 'archived' | 'all';

View File

@@ -22,6 +22,8 @@ import { useClientsStore } from '@/utils/useClients';
import { useClientsQuery } from '@/utils/useClientsQuery'; import { useClientsQuery } from '@/utils/useClientsQuery';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { import {
useVueTable, useVueTable,
getCoreRowModel, getCoreRowModel,
@@ -29,6 +31,8 @@ import {
type SortingState, type SortingState,
} from '@tanstack/vue-table'; } from '@tanstack/vue-table';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const props = defineProps<{ const props = defineProps<{
projects: Project[]; projects: Project[];
showBillableRate: boolean; showBillableRate: boolean;
@@ -155,6 +159,7 @@ const gridTemplate = computed(() => {
:create-project :create-project
:create-client :create-client
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:organization-billable-rate="organization?.billable_rate ?? null"
:clients="clients" :clients="clients"
:enable-estimated-time="isAllowedToPerformPremiumAction()"></ProjectCreateModal> :enable-estimated-time="isAllowedToPerformPremiumAction()"></ProjectCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto"> <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 type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue'; import { computed, ref, inject, type ComputedRef } from 'vue';
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline'; 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 { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery'; import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsStore } from '@/utils/useProjects'; 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 UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canUpdateProjects, canDeleteProjects } from '@/utils/permissions';
import type { Organization } from '@/packages/api/src'; import type { Organization } from '@/packages/api/src';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/packages/ui/src';
const { clients } = useClientsQuery(); const { clients } = useClientsQuery();
const { tasks } = useTasksQuery(); const { tasks } = useTasksQuery();
@@ -69,71 +82,99 @@ const showEditProjectModal = ref(false);
<ProjectEditModal <ProjectEditModal
v-model:show="showEditProjectModal" v-model:show="showEditProjectModal"
:original-project="project"></ProjectEditModal> :original-project="project"></ProjectEditModal>
<TableRow :href="route('projects.show', { project: project.id })"> <ContextMenu>
<div <ContextMenuTrigger as-child>
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"> <TableRow :href="route('projects.show', { project: project.id })">
<div <div
:style="{ 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">
backgroundColor: project.color, <div
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`, :style="{
}" backgroundColor: project.color,
class="w-3 h-3 rounded-full"></div> boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
<span class="overflow-ellipsis overflow-hidden"> }"
{{ project.name }} class="w-3 h-3 rounded-full"></div>
</span> <span class="overflow-ellipsis overflow-hidden">
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span> {{ project.name }}
</div> </span>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary"> <span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden"> </div>
{{ client?.name }} <div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
</div> <div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
<div v-else>No client</div> {{ client?.name }}
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> <div v-else>No client</div>
<div v-if="project.spent_time"> </div>
{{ <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
formatHumanReadableDuration( <div v-if="project.spent_time">
project.spent_time, {{
organization?.interval_format, formatHumanReadableDuration(
organization?.number_format project.spent_time,
) organization?.interval_format,
}} organization?.number_format
</div> )
<div v-else>--</div> }}
</div> </div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary"> <div v-else>--</div>
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge> </div>
<EstimatedTimeProgress <div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
v-else-if="project.estimated_time" <UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
:estimated="project.estimated_time" <EstimatedTimeProgress
:current="project.spent_time"></EstimatedTimeProgress> v-else-if="project.estimated_time"
<span v-else> -- </span> :estimated="project.estimated_time"
</div> :current="project.spent_time"></EstimatedTimeProgress>
<div <span v-else> -- </span>
v-if="showBillableRate" </div>
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> <div
{{ billableRateInfo }} v-if="showBillableRate"
</div> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div {{ billableRateInfo }}
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium"> </div>
<template v-if="project.is_archived"> <div
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<span>Archived</span> <template v-if="project.is_archived">
</template> <ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<template v-else> <span>Archived</span>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon> </template>
<span>Active</span> <template v-else>
</template> <CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
</div> <span>Active</span>
<div </template>
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"> </div>
<ProjectMoreOptionsDropdown <div
:project="project" 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">
@edit="showEditProjectModal = true" <ProjectMoreOptionsDropdown
@archive="archiveProject" :project="project"
@delete="deleteProject"></ProjectMoreOptionsDropdown> @edit="showEditProjectModal = true"
</div> @archive="archiveProject"
</TableRow> @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> </template>
<style scoped></style> <style scoped></style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ import {
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import type { AggregatedTimeEntries, Organization } from '@/packages/api/src'; 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]); use([CanvasRenderer, BarChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);

View File

@@ -6,7 +6,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu'; } from '@/packages/ui/src';
import type { ExportFormat } from '@/types/reporting'; import type { ExportFormat } from '@/types/reporting';
import { ref } from 'vue'; import { ref } from 'vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; 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 MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue'; import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue'; import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue'; import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ defineProps<{
<template> <template>
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5"> <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> <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 ?? '--' }} {{ value ?? '--' }}
</dd> </dd>
</div> </div>

View File

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

View File

@@ -6,6 +6,14 @@ import TagEditModal from '@/Components/Common/Tag/TagEditModal.vue';
import TableRow from '@/Components/TableRow.vue'; import TableRow from '@/Components/TableRow.vue';
import { canDeleteTags, canUpdateTags } from '@/utils/permissions'; import { canDeleteTags, canUpdateTags } from '@/utils/permissions';
import { ref } from 'vue'; 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<{ const props = defineProps<{
tag: Tag; tag: Tag;
@@ -19,23 +27,44 @@ function deleteTag() {
</script> </script>
<template> <template>
<TableRow> <ContextMenu>
<div <ContextMenuTrigger as-child>
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"> <TableRow>
<span> <div
{{ tag.name }} 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> <span>
</div> {{ tag.name }}
<div </span>
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"> </div>
<TagMoreOptionsDropdown <div
v-if="canDeleteTags() || canUpdateTags()" 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">
:tag="tag" <TagMoreOptionsDropdown
@edit="showTagEditModal = true" v-if="canDeleteTags() || canUpdateTags()"
@delete="deleteTag"></TagMoreOptionsDropdown> :tag="tag"
</div> @edit="showTagEditModal = true"
<TagEditModal v-model:show="showTagEditModal" :tag="tag"></TagEditModal> @delete="deleteTag"></TagMoreOptionsDropdown>
</TableRow> </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> </template>
<style scoped></style> <style scoped></style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import {
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; 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'; import type { Organization } from '@/packages/api/src';
use([CanvasRenderer, PieChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]); 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 { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import { getWeekStart } from '@/packages/ui/src/utils/settings'; 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 { getOrganizationCurrencyString } from '@/utils/money';
import { useQuery } from '@tanstack/vue-query'; import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
} from '@/Components/ui/dropdown-menu'; } from '@/packages/ui/src';
import { import {
UserCircleIcon, UserCircleIcon,
KeyIcon, 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 AppLayout from '@/Layouts/AppLayout.vue';
import { useTimeEntriesCalendarQuery } from '@/utils/useTimeEntriesCalendarQuery'; import { useTimeEntriesCalendarQuery } from '@/utils/useTimeEntriesCalendarQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations'; import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { computed, ref } from 'vue'; import { computed, ref, onMounted } from 'vue';
import { useQueryClient } from '@tanstack/vue-query'; import { useQueryClient } from '@tanstack/vue-query';
import { import {
type Client, type Client,
@@ -11,6 +11,7 @@ import {
type Project, type Project,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { TimeEntryCalendar } from '@/packages/ui/src'; import { TimeEntryCalendar } from '@/packages/ui/src';
import type { ActivityPeriod } from '@/packages/ui/src/FullCalendar/activityTypes';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useTagsStore } from '@/utils/useTags'; import { useTagsStore } from '@/utils/useTags';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
@@ -21,10 +22,34 @@ import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { canCreateProjects } from '@/utils/permissions'; 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 calendarStart = ref<Date | undefined>(undefined);
const calendarEnd = 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( const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useTimeEntriesCalendarQuery(
calendarStart, calendarStart,
calendarEnd calendarEnd
@@ -81,13 +106,17 @@ function onDatesChange({ start, end }: { start: Date; end: Date }) {
function onRefresh() { function onRefresh() {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['timeEntries', 'calendar'], queryKey: ['timeEntries'],
}); });
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
} }
</script> </script>
<template> <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 <TimeEntryCalendar
:time-entries="currentTimeEntries" :time-entries="currentTimeEntries"
:projects="projects" :projects="projects"
@@ -98,12 +127,14 @@ function onRefresh() {
:enable-estimated-time="isAllowedToPerformPremiumAction()" :enable-estimated-time="isAllowedToPerformPremiumAction()"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:can-create-project="canCreateProjects()" :can-create-project="canCreateProjects()"
:organization-billable-rate="organization?.billable_rate ?? null"
:create-time-entry="createTimeEntry" :create-time-entry="createTimeEntry"
:update-time-entry="updateTimeEntry" :update-time-entry="updateTimeEntry"
:delete-time-entry="deleteTimeEntry" :delete-time-entry="deleteTimeEntry"
:create-client="createClient" :create-client="createClient"
:create-project="createProject" :create-project="createProject"
:create-tag="createTag" :create-tag="createTag"
:activity-periods="testActivityPeriods"
@dates-change="onDatesChange" @dates-change="onDatesChange"
@refresh="onRefresh" /> @refresh="onRefresh" />
</AppLayout> </AppLayout>

View File

@@ -10,8 +10,7 @@ import ClientTable from '@/Components/Common/Client/ClientTable.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue'; import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateClients } from '@/utils/permissions'; import { canCreateClients } from '@/utils/permissions';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue'; import { TabBar, TabBarItem } from '@/packages/ui/src';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue'; 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 { PlusIcon } from '@heroicons/vue/16/solid';
import { UserGroupIcon } from '@heroicons/vue/20/solid'; import { UserGroupIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue'; import { TabBar, TabBarItem } from '@/packages/ui/src';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import { ref } from 'vue'; import { ref } from 'vue';
import MemberTable from '@/Components/Common/Member/MemberTable.vue'; import MemberTable from '@/Components/Common/Member/MemberTable.vue';
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue'; import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';

View File

@@ -1,13 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import FormSection from '@/Components/FormSection.vue'; import FormSection from '@/Components/FormSection.vue';
import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field'; import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { usePreferredColorScheme } from '@vueuse/core'; import { usePreferredColorScheme } from '@vueuse/core';
import { themeSetting } from '@/utils/theme'; 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 ProjectMemberCreateModal from '@/Components/Common/ProjectMember/ProjectMemberCreateModal.vue';
import { useProjectMembersQuery } from '@/utils/useProjectMembersQuery'; import { useProjectMembersQuery } from '@/utils/useProjectMembersQuery';
import { canCreateProjects, canCreateTasks, canViewProjectMembers } from '@/utils/permissions'; import { canCreateProjects, canCreateTasks, canViewProjectMembers } from '@/utils/permissions';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue'; import { TabBar, TabBarItem } from '@/packages/ui/src';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import { useTasksQuery } from '@/utils/useTasksQuery'; import { useTasksQuery } from '@/utils/useTasksQuery';
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue'; import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
import { Badge } from '@/packages/ui/src'; import { Badge } from '@/packages/ui/src';

View File

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

View File

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

View File

@@ -6,13 +6,7 @@ import { Field, FieldLabel } from '@/packages/ui/src/field';
import type { UpdateOrganizationBody } from '@/packages/api/src'; import type { UpdateOrganizationBody } from '@/packages/api/src';
import { useOrganizationStore } from '@/utils/useOrganization'; import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { useMutation, useQueryClient } from '@tanstack/vue-query'; import { useMutation, useQueryClient } from '@tanstack/vue-query';
import type { DateFormat, TimeFormat, IntervalFormat } from '@/packages/ui/src/utils/time'; import type { DateFormat, TimeFormat, IntervalFormat } from '@/packages/ui/src/utils/time';
import type { CurrencyFormat } from '@/packages/ui/src/utils/money'; 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 type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useTagsStore } from '@/utils/useTags'; import { useTagsStore } from '@/utils/useTags';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
@@ -87,6 +89,8 @@ async function createClient(body: CreateClientBody): Promise<Client | undefined>
return await useClientsStore().createClient(body); return await useClientsStore().createClient(body);
} }
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const selectedTimeEntries = ref([] as TimeEntry[]); const selectedTimeEntries = ref([] as TimeEntry[]);
async function clearSelectionAndState() { async function clearSelectionAndState() {
@@ -115,6 +119,7 @@ function deleteSelected() {
:tags="tags" :tags="tags"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:clients="clients" :clients="clients"
:organization-billable-rate="organization?.billable_rate ?? null"
class="border-t border-default-background-separator hidden sm:block" class="border-t border-default-background-separator hidden sm:block"
:update-time-entries=" :update-time-entries="
(args) => (args) =>
@@ -134,6 +139,7 @@ function deleteSelected() {
:create-project :create-project
:enable-estimated-time="isAllowedToPerformPremiumAction()" :enable-estimated-time="isAllowedToPerformPremiumAction()"
:can-create-project="canCreateProjects()" :can-create-project="canCreateProjects()"
:organization-billable-rate="organization?.billable_rate ?? null"
:clients :clients
:create-client :create-client
:update-time-entry :update-time-entry
@@ -157,7 +163,7 @@ function deleteSelected() {
<div ref="loadMoreContainer"> <div ref="loadMoreContainer">
<div <div
v-if="isFetchingNextPage" 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> <LoadingSpinner></LoadingSpinner>
<span> Loading more time entries... </span> <span> Loading more time entries... </span>
</div> </div>

View File

@@ -1,6 +1,6 @@
{ {
"name": "@solidtime/ui", "name": "@solidtime/ui",
"version": "0.0.16", "version": "0.0.17",
"description": "Package containing the solidtime ui components", "description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs", "main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js", "module": "./dist/solidtime-ui-lib.js",
@@ -21,7 +21,7 @@
"default": "./dist/solidtime-ui-lib.umd.cjs" "default": "./dist/solidtime-ui-lib.umd.cjs"
} }
}, },
"./style.css": "./dist/style.css", "./style.css": "./dist/solidtime-ui-lib.css",
"./styles.css": "./styles.css", "./styles.css": "./styles.css",
"./tailwind.theme.js": "./tailwind.theme.js" "./tailwind.theme.js": "./tailwind.theme.js"
}, },
@@ -48,17 +48,26 @@
"author": "solidtime", "author": "solidtime",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "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": { "peerDependencies": {
"@floating-ui/vue": "^1.1.4", "@floating-ui/vue": "^1.1.4",
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.1.5",
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0", "@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
"@vueuse/core": "^12.5.0 || ^14.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", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"lucide-vue-next": ">=0.453.0",
"@internationalized/date": "^3.0.0",
"parse-duration": "^2.0.1", "parse-duration": "^2.0.1",
"radix-vue": "^1.9.0",
"reka-ui": "^2.2.0", "reka-ui": "^2.2.0",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwindcss": "^3.1.0", "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"> <script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger, Button } from '..'; import { Popover, PopoverContent, PopoverTrigger, Button } from '..';
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Field, FieldLabel } from '../field'; import { Field, FieldLabel } from '../field';
import { Settings } from 'lucide-vue-next'; import { Settings } from 'lucide-vue-next';
import { ref, watch } from 'vue'; 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"> <script setup lang="ts">
import { computed, inject, type ComputedRef } from 'vue'; 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 { Organization } from '@/packages/api/src';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
const props = defineProps<{ const props = defineProps<{
date: Dayjs; date: Dayjs;
totalSeconds?: number; totalSeconds?: number;
isToday?: boolean;
}>(); }>();
const totalSecondsValue = computed(() => props.totalSeconds ?? 0); const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
// Injected organization for formatting settings
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined; const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
const intervalFormat = computed(() => organization?.value?.interval_format); const intervalFormat = computed(() => organization?.value?.interval_format);
const numberFormat = computed(() => organization?.value?.number_format); const numberFormat = computed(() => organization?.value?.number_format);
const dateFormat = computed(() => organization?.value?.date_format);
</script> </script>
<template> <template>
<div class="fc-day-header-custom"> <div class="fc-day-header-custom">
<div class="text-xs text-muted-foreground font-medium"> <div class="text-sm text-foreground" :class="isToday ? 'font-semibold' : 'font-medium'">
{{ date.format('ddd') }} {{ date.format('ddd') }} {{ date.date() }}
</div> </div>
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span> <span class="block text-xs text-muted-foreground font-medium mt-0.5">
<span class="block text-xs text-muted-foreground font-medium mt-1">
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }} {{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
</span> </span>
</div> </div>

View File

@@ -40,7 +40,7 @@ const formattedDuration = computed(() =>
</script> </script>
<template> <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 class="font-semibold">{{ title }}</div>
<div v-if="projectName" class="font-medium opacity-90"> <div v-if="projectName" class="font-medium opacity-90">
{{ projectName }} {{ projectName }}
@@ -51,7 +51,7 @@ const formattedDuration = computed(() =>
<div v-if="clientName" class="opacity-85"> <div v-if="clientName" class="opacity-85">
{{ clientName }} {{ clientName }}
</div> </div>
<div class="opacity-90"> <div class="opacity-90" data-duration>
{{ formattedDuration }} {{ formattedDuration }}
</div> </div>
</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,186 +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;
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.round(top / snapPx) * snapPx;
const snappedEnd = Math.round(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.round(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.round(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.round(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 <button
:class=" :class="
twMerge( 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, expandedStatusClasses,
props.size props.size
) )

View File

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

View File

@@ -7,15 +7,17 @@ import {
type WeekStartDay, type WeekStartDay,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src/popover'; 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 { 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 { parseDate, type DateValue } from '@internationalized/date';
import type { Organization } from '@/packages/api/src'; import type { Organization } from '@/packages/api/src';
const props = defineProps<{ const props = defineProps<{
tabindex?: string; tabindex?: string;
class?: string; class?: string;
size?: 'sm' | 'default';
clearable?: boolean;
}>(); }>();
// This has to be a localized timestamp, not UTC // This has to be a localized timestamp, not UTC
@@ -59,6 +61,12 @@ function handleDateSelect(newDate: DateValue | undefined) {
emit('changed', newValue); emit('changed', newValue);
open.value = false; open.value = false;
} }
function handleClear(event: Event) {
event.stopPropagation();
model.value = null;
emit('changed', null);
}
</script> </script>
<template> <template>
@@ -67,11 +75,18 @@ function handleDateSelect(newDate: DateValue | undefined) {
<PopoverTrigger as-child> <PopoverTrigger as-child>
<Button <Button
variant="input" variant="input"
size="sm" :size="props.size ?? 'default'"
:tabindex="tabindex" :tabindex="tabindex"
:class="['w-full px-2 gap-1.5', props.class]"> :class="['w-full px-2 gap-1.5', props.class]">
<CalendarIcon class="!size-3 text-muted-foreground" /> <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> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-auto p-0" align="center"> <PopoverContent class="w-auto p-0" align="center">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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