mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-18 21:22:15 +01:00
Compare commits
75 Commits
v0.11.5
...
e56386d2c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e56386d2c3 | ||
|
|
7f75e5f1b5 | ||
|
|
8941aec72b | ||
|
|
bc1f4deaa0 | ||
|
|
d292396dd4 | ||
|
|
f926e8fde3 | ||
|
|
b660486eb7 | ||
|
|
61db23fc01 | ||
|
|
ab761d97f9 | ||
|
|
50d60393bc | ||
|
|
b5639e4bb2 | ||
|
|
db4af2bcac | ||
|
|
b73aa543fd | ||
|
|
2d6f9e514f | ||
|
|
f8e668790b | ||
|
|
77a5e979c6 | ||
|
|
353a579850 | ||
|
|
bd44a2b376 | ||
|
|
277dbaf6eb | ||
|
|
1cf33ddb3f | ||
|
|
84cd0d572d | ||
|
|
f37b86f377 | ||
|
|
1e7364fc4b | ||
|
|
8cbc9838c9 | ||
|
|
71c8992e31 | ||
|
|
53d91b65d6 | ||
|
|
0c88a10eb5 | ||
|
|
dd7b23958a | ||
|
|
1eb066f5aa | ||
|
|
b1287c6a0a | ||
|
|
815abb5980 | ||
|
|
e2f859be27 | ||
|
|
3d26fcaefe | ||
|
|
1e73a90f9d | ||
|
|
0f8f906e5c | ||
|
|
797fddf638 | ||
|
|
d07294ae7c | ||
|
|
1f49940805 | ||
|
|
6be6a48e0d | ||
|
|
b94a04dca0 | ||
|
|
bd3b8f265f | ||
|
|
c19a0f9acc | ||
|
|
5c6d84dc38 | ||
|
|
5c67709746 | ||
|
|
a2b0828c54 | ||
|
|
b94872b07b | ||
|
|
12bbbf64e9 | ||
|
|
c07ac4b0e4 | ||
|
|
a58566d002 | ||
|
|
57ed6036e6 | ||
|
|
ef7569b63b | ||
|
|
19c789b78e | ||
|
|
49548037b3 | ||
|
|
97df779d1e | ||
|
|
a1d5563fc4 | ||
|
|
c94ca804f8 | ||
|
|
189682cfaf | ||
|
|
8d16503541 | ||
|
|
e43ce477b8 | ||
|
|
5646aedb25 | ||
|
|
2b46e568e0 | ||
|
|
89a4a1962a | ||
|
|
c581ad8854 | ||
|
|
bce6cb9395 | ||
|
|
1cdae98ed9 | ||
|
|
02f6436fd0 | ||
|
|
452acca942 | ||
|
|
192c8c3b88 | ||
|
|
6218ffceb5 | ||
|
|
ba32be0543 | ||
|
|
bd817db06f | ||
|
|
97f4bce676 | ||
|
|
6962b668fb | ||
|
|
be8091296c | ||
|
|
84c4750c9b |
2
.env.ci
2
.env.ci
@@ -60,7 +60,7 @@ AUDITING_ENABLED=true
|
|||||||
TELESCOPE_ENABLED=false
|
TELESCOPE_ENABLED=false
|
||||||
|
|
||||||
# Services
|
# Services
|
||||||
GOTENBERG_URL=http://0.0.0.0:3000
|
GOTENBERG_URL=http://localhost:3000
|
||||||
|
|
||||||
# Octane
|
# Octane
|
||||||
OCTANE_SERVER=frankenphp
|
OCTANE_SERVER=frankenphp
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ TELESCOPE_ENABLED=false
|
|||||||
# Services
|
# Services
|
||||||
GOTENBERG_URL=http://gotenberg:3000
|
GOTENBERG_URL=http://gotenberg:3000
|
||||||
|
|
||||||
|
# Octane
|
||||||
|
OCTANE_SERVER=frankenphp
|
||||||
|
|
||||||
# Local setup
|
# Local setup
|
||||||
NGINX_HOST_NAME=solidtime.test
|
NGINX_HOST_NAME=solidtime.test
|
||||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -628,9 +629,9 @@ class TimeEntryController extends Controller
|
|||||||
/** @var Member|null $member */
|
/** @var Member|null $member */
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
||||||
$this->checkPermission($organization, 'time-entries:update:own');
|
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:update:all');
|
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
||||||
|
|||||||
@@ -96,6 +96,30 @@ class LocalizationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a duration for reporting contexts (PDF reports, places that display duration
|
||||||
|
* directly next to cost). Promotes the verbose `Hh Mm` format to the compact `HH:MM:SS`
|
||||||
|
* so totals stay narrow and reconcile with cost, which is always computed to the second.
|
||||||
|
*/
|
||||||
|
public function formatIntervalForReporting(CarbonInterval $interval): string
|
||||||
|
{
|
||||||
|
$promoted = [
|
||||||
|
IntervalFormat::HoursMinutes,
|
||||||
|
IntervalFormat::HoursMinutesColonSeparated,
|
||||||
|
];
|
||||||
|
if (! in_array($this->intervalFormat, $promoted, true)) {
|
||||||
|
return $this->formatInterval($interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous = $this->intervalFormat;
|
||||||
|
$this->intervalFormat = IntervalFormat::HoursMinutesSecondsColonSeparated;
|
||||||
|
try {
|
||||||
|
return $this->formatInterval($interval);
|
||||||
|
} finally {
|
||||||
|
$this->intervalFormat = $previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function formatCurrency(Money $money): string
|
public function formatCurrency(Money $money): string
|
||||||
{
|
{
|
||||||
$currencyService = app(CurrencyService::class);
|
$currencyService = app(CurrencyService::class);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class TimeEntryFilter
|
|||||||
if ($start === null) {
|
if ($start === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$this->builder->where('start', '>', $start);
|
$this->builder->where('start', '>=', $start);
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
540
composer.lock
generated
540
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
2566
e2e/calendar.spec.ts
2566
e2e/calendar.spec.ts
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -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
|
||||||
// =============================================
|
// =============================================
|
||||||
@@ -755,7 +907,7 @@ test.describe('Employee Sidebar Navigation', () => {
|
|||||||
|
|
||||||
// Visible links
|
// Visible links
|
||||||
await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
|
await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
|
||||||
await expect(employee.page.getByRole('link', { name: 'Time' })).toBeVisible();
|
await expect(employee.page.getByRole('link', { name: 'Time', exact: true })).toBeVisible();
|
||||||
await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible();
|
await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible();
|
||||||
await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible();
|
await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible();
|
||||||
await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible();
|
await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible();
|
||||||
|
|||||||
@@ -230,6 +230,37 @@ test('test that theme can be changed to dark and light', async ({ page }) => {
|
|||||||
await expect(page.getByText('System default:')).toBeVisible();
|
await expect(page.getByText('System default:')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Group similar time entries
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that group similar time entries setting can be toggled', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
|
||||||
|
// Get the checkbox
|
||||||
|
const checkbox = page.getByLabel('Group similar time entries');
|
||||||
|
|
||||||
|
// Get initial value and verify it is checked (default is true)
|
||||||
|
const initialValue = await checkbox.isChecked();
|
||||||
|
await expect(checkbox).toBeChecked();
|
||||||
|
|
||||||
|
// Toggle the checkbox
|
||||||
|
await checkbox.click();
|
||||||
|
|
||||||
|
// Reload
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify the value is toggled
|
||||||
|
const afterValue = await page.getByLabel('Group similar time entries').isChecked();
|
||||||
|
expect(afterValue).toBe(!initialValue);
|
||||||
|
|
||||||
|
// Verify localStorage persists the setting
|
||||||
|
const storedValue = await page.evaluate(() =>
|
||||||
|
localStorage.getItem('group-similar-time-entries')
|
||||||
|
);
|
||||||
|
expect(storedValue).toBe(String(!initialValue));
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Two Factor Authentication Tests
|
// Two Factor Authentication Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ test('test that creating a project with estimated time in human-readable format
|
|||||||
await page.getByLabel('Project Name').fill(newProjectName);
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
|
||||||
// Fill in estimated time using human-readable format
|
// Fill in estimated time using human-readable format
|
||||||
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
const estimatedTimeInput = page.getByLabel('Time Estimated');
|
||||||
await estimatedTimeInput.fill('2h 30m');
|
await estimatedTimeInput.fill('2h 30m');
|
||||||
await estimatedTimeInput.press('Tab');
|
await estimatedTimeInput.press('Tab');
|
||||||
|
|
||||||
@@ -668,7 +668,7 @@ test('test that creating a project with estimated time using decimal notation wo
|
|||||||
await page.getByLabel('Project Name').fill(newProjectName);
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
|
||||||
// Fill in estimated time using decimal notation (1.5 hours = 1h 30m)
|
// Fill in estimated time using decimal notation (1.5 hours = 1h 30m)
|
||||||
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
const estimatedTimeInput = page.getByLabel('Time Estimated');
|
||||||
await estimatedTimeInput.fill('1.5');
|
await estimatedTimeInput.fill('1.5');
|
||||||
await estimatedTimeInput.press('Tab');
|
await estimatedTimeInput.press('Tab');
|
||||||
|
|
||||||
@@ -696,7 +696,7 @@ test('test that creating a project with estimated time using comma decimal notat
|
|||||||
await page.getByLabel('Project Name').fill(newProjectName);
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
|
||||||
// Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m)
|
// Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m)
|
||||||
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
const estimatedTimeInput = page.getByLabel('Time Estimated');
|
||||||
await estimatedTimeInput.fill('2,5');
|
await estimatedTimeInput.fill('2,5');
|
||||||
await estimatedTimeInput.press('Tab');
|
await estimatedTimeInput.press('Tab');
|
||||||
|
|
||||||
@@ -727,7 +727,7 @@ test('test that updating estimated time on existing project works', async ({ pag
|
|||||||
await page.getByRole('menuitem').getByText('Edit').first().click();
|
await page.getByRole('menuitem').getByText('Edit').first().click();
|
||||||
|
|
||||||
// Fill in estimated time
|
// Fill in estimated time
|
||||||
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
const estimatedTimeInput = page.getByLabel('Time Estimated');
|
||||||
await estimatedTimeInput.fill('4h 15m');
|
await estimatedTimeInput.fill('4h 15m');
|
||||||
await estimatedTimeInput.press('Tab');
|
await estimatedTimeInput.press('Tab');
|
||||||
|
|
||||||
@@ -748,7 +748,7 @@ test('test that estimated time input displays formatted value after blur', async
|
|||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
|
|
||||||
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
const estimatedTimeInput = page.getByLabel('Time Estimated');
|
||||||
|
|
||||||
// Enter time in various formats and check the displayed value
|
// Enter time in various formats and check the displayed value
|
||||||
await estimatedTimeInput.fill('90');
|
await estimatedTimeInput.fill('90');
|
||||||
@@ -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
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test('test that detailed view shows time entries correctly', async ({ page, ctx
|
|||||||
|
|
||||||
// Verify the time entry is shown with all details
|
// Verify the time entry is shown with all details
|
||||||
await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();
|
await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();
|
||||||
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1h 00min');
|
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1:00:00');
|
||||||
await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();
|
await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,8 +62,8 @@ test('test that updating duration in detailed view works correctly', async ({ pa
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Verify the new duration is displayed
|
// Verify the new duration is displayed (reporting views promote to HH:MM:SS format)
|
||||||
await expect(durationInput).toHaveValue(updatedDuration);
|
await expect(durationInput).toHaveValue('2:30:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ test('test that task filtering works in reporting', async ({ page, ctx }) => {
|
|||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Verify the report only shows 1h (task1's duration)
|
// Verify the report only shows 1h (task1's duration)
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that task multiselect search filters the option list', async ({ page, ctx }) => {
|
test('test that task multiselect search filters the option list', async ({ page, ctx }) => {
|
||||||
@@ -474,7 +474,7 @@ test('test that tag filtering works in reporting', async ({ page, ctx }) => {
|
|||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Verify only time entries with tag1 are shown
|
// Verify only time entries with tag1 are shown
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that tag dropdown search filters the option list', async ({ page, ctx }) => {
|
test('test that tag dropdown search filters the option list', async ({ page, ctx }) => {
|
||||||
@@ -594,7 +594,7 @@ test('test that billable status filtering works in reporting', async ({ page, ct
|
|||||||
waitForReportingUpdate(page),
|
waitForReportingUpdate(page),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that billable filter can switch between all three states', async ({ page }) => {
|
test('test that billable filter can switch between all three states', async ({ page }) => {
|
||||||
@@ -885,7 +885,7 @@ test.describe('Employee Reporting Restrictions', () => {
|
|||||||
|
|
||||||
// Employee's data should be visible (1h)
|
// Employee's data should be visible (1h)
|
||||||
await expect(
|
await expect(
|
||||||
employee.page.getByTestId('reporting_view').getByText('1h 00min').first()
|
employee.page.getByTestId('reporting_view').getByText('1:00:00').first()
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -287,8 +292,8 @@ test('test that shared report respects task filter', async ({ page, ctx }) => {
|
|||||||
await page.goto(shareableLink);
|
await page.goto(shareableLink);
|
||||||
await expect(page.getByText('Reporting')).toBeVisible();
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
await expect(page.getByText('Total')).toBeVisible();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByText('1:00:00').first()).toBeVisible();
|
||||||
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
await expect(page.getByText('3:00:00')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that shared report respects client filter', async ({ page, ctx }) => {
|
test('test that shared report respects client filter', async ({ page, ctx }) => {
|
||||||
@@ -364,8 +369,8 @@ test('test that shared report respects tag filter', async ({ page, ctx }) => {
|
|||||||
await page.goto(shareableLink);
|
await page.goto(shareableLink);
|
||||||
await expect(page.getByText('Reporting')).toBeVisible();
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
await expect(page.getByText('Total')).toBeVisible();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByText('1:00:00').first()).toBeVisible();
|
||||||
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
await expect(page.getByText('3:00:00')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that shared report respects member filter', async ({ page, ctx }) => {
|
test('test that shared report respects member filter', async ({ page, ctx }) => {
|
||||||
@@ -420,7 +425,7 @@ test('test that shared report with billable filter only shows billable entries',
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Verify only 1h shows before saving
|
// Verify only 1h shows before saving
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||||
|
|
||||||
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||||
|
|
||||||
@@ -430,8 +435,8 @@ test('test that shared report with billable filter only shows billable entries',
|
|||||||
await expect(page.getByText('Total')).toBeVisible();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
|
|
||||||
// Shared report should only show the 1h billable entry, not the 2h non-billable
|
// Shared report should only show the 1h billable entry, not the 2h non-billable
|
||||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByText('1:00:00').first()).toBeVisible();
|
||||||
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
await expect(page.getByText('3:00:00')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
@@ -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
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
776
e2e/time.spec.ts
776
e2e/time.spec.ts
@@ -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
|
||||||
@@ -38,6 +39,10 @@ function getMonthFromTimestamp(timestamp: string): number {
|
|||||||
return new Date(timestamp).getUTCMonth() + 1;
|
return new Date(timestamp).getUTCMonth() + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function goToProfilePage(page: Page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
|
}
|
||||||
|
|
||||||
async function goToTimeOverview(page: Page) {
|
async function goToTimeOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||||
}
|
}
|
||||||
@@ -66,6 +71,14 @@ async function createEmptyTimeEntry(page: Page) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setTimeEntriesGrouping(page: Page, enabled: boolean) {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
const checkbox = page.getByLabel('Group similar time entries');
|
||||||
|
const isChecked = await checkbox.isChecked();
|
||||||
|
if (isChecked !== enabled) await checkbox.click();
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
}
|
||||||
|
|
||||||
test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({
|
test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -332,6 +345,30 @@ test.skip('test that load more works when the end of page is reached', async ({
|
|||||||
await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
|
await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that Group similar time entries option is affected', async ({ page }) => {
|
||||||
|
// Enable grouping
|
||||||
|
await setTimeEntriesGrouping(page, true);
|
||||||
|
|
||||||
|
// Create 2 similar time entries
|
||||||
|
await createEmptyTimeEntry(page);
|
||||||
|
await page.waitForSelector('[data-testid="time_entry_row"]', { timeout: 1000 });
|
||||||
|
await createEmptyTimeEntry(page);
|
||||||
|
|
||||||
|
// Verify similar time entries are grouped
|
||||||
|
await expect(page.getByTestId('grouped_items_count_button').first()).toBeVisible({
|
||||||
|
timeout: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable grouping
|
||||||
|
await setTimeEntriesGrouping(page, false);
|
||||||
|
|
||||||
|
// Verify similar time entries are not grouped
|
||||||
|
await expect(page.locator('[data-testid="time_entry_row"]')).toHaveCount(2, { timeout: 1000 });
|
||||||
|
await expect(page.locator('[data-testid="grouped_items_count_button"]')).toHaveCount(0, {
|
||||||
|
timeout: 1000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: Test that updating the time entry start / end times works while it is running
|
// TODO: Test that updating the time entry start / end times works while it is running
|
||||||
|
|
||||||
// TODO: Test for project update
|
// TODO: Test for project update
|
||||||
@@ -608,7 +645,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 +658,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 +1000,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 +1020,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 +1038,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 +2078,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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from './utils/currentTimeEntry';
|
} from './utils/currentTimeEntry';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { newTagResponse } from './utils/tags';
|
import { newTagResponse } from './utils/tags';
|
||||||
import { updateOrganizationCurrencyViaWeb } from './utils/api';
|
import { createProjectViaApi, updateOrganizationCurrencyViaWeb } from './utils/api';
|
||||||
|
|
||||||
// Date picker button name patterns for different date formats
|
// Date picker button name patterns for different date formats
|
||||||
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
||||||
@@ -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();
|
||||||
@@ -368,6 +368,45 @@ test('test that timer started on dashboard is visible on time page', async ({ pa
|
|||||||
await assertThatTimerIsStopped(page);
|
await assertThatTimerIsStopped(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that creating a new project from the time tracker dropdown prefills the search text', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const existingProjectName = 'Existing Project ' + Math.floor(Math.random() * 10000);
|
||||||
|
const searchText = 'PrefillProject ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create a project so the dropdown renders (not the "Add new project" button)
|
||||||
|
await createProjectViaApi(ctx, { name: existingProjectName });
|
||||||
|
await goToDashboard(page);
|
||||||
|
|
||||||
|
// Open the project dropdown
|
||||||
|
await page.getByRole('button', { name: 'No Project' }).click();
|
||||||
|
|
||||||
|
// Type a search term that won't match any existing project
|
||||||
|
await page.getByTestId('client_dropdown_search').fill(searchText);
|
||||||
|
|
||||||
|
// Click "Create new Project"
|
||||||
|
await page.getByText('Create new Project').click();
|
||||||
|
|
||||||
|
// Verify the project name input is pre-filled with the search text
|
||||||
|
await expect(page.getByLabel('Project name')).toHaveValue(searchText);
|
||||||
|
|
||||||
|
// Complete project creation to verify full flow works
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201 &&
|
||||||
|
(await response.json()).data.name === searchText
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The project dropdown should now show the newly created project
|
||||||
|
await expect(page.getByRole('button', { name: searchText })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('test that adding a project and tag before starting timer works', async ({ page }) => {
|
test('test that adding a project and tag before starting timer works', async ({ page }) => {
|
||||||
const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);
|
const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);
|
||||||
await goToDashboard(page);
|
await goToDashboard(page);
|
||||||
|
|||||||
243
e2e/utils/api.ts
243
e2e/utils/api.ts
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
560
package-lock.json
generated
560
package-lock.json
generated
@@ -4,6 +4,7 @@
|
|||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"name": "solidtime",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"resources/js/packages/ui",
|
"resources/js/packages/ui",
|
||||||
"resources/js/packages/api"
|
"resources/js/packages/api"
|
||||||
@@ -11,11 +12,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 +21,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 +34,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 +134,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 +1033,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 +1142,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 +1207,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 +1233,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 +1281,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 +1341,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 +1483,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 +1496,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 +1509,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 +1522,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 +1535,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 +1548,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 +1561,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 +1574,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 +1587,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 +1600,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 +1613,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 +1626,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 +1639,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 +1652,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 +1665,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 +1678,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 +1691,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 +1704,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 +1717,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 +1730,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 +1743,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 +1756,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 +1769,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 +1782,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 +1795,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 +1814,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 +1839,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 +1929,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 +1944,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 +1955,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 +1991,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 +2608,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 +2943,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 +2959,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 +3037,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 +3063,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 +3116,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 +3150,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 +3282,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 +5056,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 +5831,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 +5885,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 +6093,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 +6109,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 +6179,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 +6194,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 +7128,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,20 +7413,29 @@
|
|||||||
},
|
},
|
||||||
"resources/js/packages/ui": {
|
"resources/js/packages/ui": {
|
||||||
"name": "@solidtime/ui",
|
"name": "@solidtime/ui",
|
||||||
"version": "0.0.15",
|
"version": "0.0.17",
|
||||||
"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",
|
||||||
|
"@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",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: [];
|
||||||
|
|||||||
@@ -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,6 +46,8 @@ const showEditModal = ref(false);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger as-child>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
|
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
|
||||||
<div
|
<div
|
||||||
@@ -42,11 +57,11 @@ const showEditModal = ref(false);
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
|
class="whitespace-nowrap flex items-center px-3 py-4 text-sm text-text-primary">
|
||||||
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
|
<span> {{ projectCount }} Projects </span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
|
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center">
|
||||||
<template v-if="client.is_archived">
|
<template v-if="client.is_archived">
|
||||||
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
|
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
|
||||||
<span>Archived</span>
|
<span>Archived</span>
|
||||||
@@ -65,6 +80,29 @@ const showEditModal = ref(false);
|
|||||||
@delete="deleteClient"></ClientMoreOptionsDropdown>
|
@delete="deleteClient"></ClientMoreOptionsDropdown>
|
||||||
</div>
|
</div>
|
||||||
</TableRow>
|
</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>
|
||||||
|
|||||||
@@ -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: [];
|
||||||
|
|||||||
@@ -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>({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: [];
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +74,8 @@ const userHasValidMailAddress = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger as-child>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
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">
|
||||||
@@ -62,27 +83,28 @@ const userHasValidMailAddress = computed(() => {
|
|||||||
{{ member.name }}
|
{{ member.name }}
|
||||||
</span>
|
</span>
|
||||||
</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-primary">
|
||||||
{{ member.email }}
|
{{ 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-primary">
|
||||||
{{ capitalizeFirstLetter(member.role) }}
|
{{ 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-primary">
|
||||||
|
<span v-if="member.billable_rate">
|
||||||
{{
|
{{
|
||||||
member.billable_rate
|
formatCents(
|
||||||
? formatCents(
|
|
||||||
member.billable_rate,
|
member.billable_rate,
|
||||||
organization?.currency,
|
organization?.currency,
|
||||||
organization?.currency_format,
|
organization?.currency_format,
|
||||||
organization?.currency_symbol,
|
organization?.currency_symbol,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
)
|
)
|
||||||
: '--'
|
|
||||||
}}
|
}}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-text-tertiary"> -- </span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
|
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center">
|
||||||
<template v-if="member.is_placeholder === false">
|
<template v-if="member.is_placeholder === false">
|
||||||
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
|
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
|
||||||
<span>Active</span>
|
<span>Active</span>
|
||||||
@@ -113,8 +135,12 @@ const userHasValidMailAddress = computed(() => {
|
|||||||
showMakeMemberPlaceholderModal = true
|
showMakeMemberPlaceholderModal = true
|
||||||
"></MemberMoreOptionsDropdown>
|
"></MemberMoreOptionsDropdown>
|
||||||
</div>
|
</div>
|
||||||
<MemberEditModal v-model:show="showEditMemberModal" :member="member"></MemberEditModal>
|
<MemberEditModal
|
||||||
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
|
v-model:show="showEditMemberModal"
|
||||||
|
:member="member"></MemberEditModal>
|
||||||
|
<MemberMergeModal
|
||||||
|
v-model:show="showMergeMemberModal"
|
||||||
|
:member="member"></MemberMergeModal>
|
||||||
<MemberMakePlaceholderModal
|
<MemberMakePlaceholderModal
|
||||||
v-model:show="showMakeMemberPlaceholderModal"
|
v-model:show="showMakeMemberPlaceholderModal"
|
||||||
:member="member"></MemberMakePlaceholderModal>
|
:member="member"></MemberMakePlaceholderModal>
|
||||||
@@ -122,6 +148,39 @@ const userHasValidMailAddress = computed(() => {
|
|||||||
v-model:show="showDeleteMemberModal"
|
v-model:show="showDeleteMemberModal"
|
||||||
:member="member"></MemberDeleteModal>
|
:member="member"></MemberDeleteModal>
|
||||||
</TableRow>
|
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()"
|
||||||
|
|||||||
@@ -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: [];
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -59,7 +72,7 @@ const billableRateInfo = computed(() => {
|
|||||||
return 'Default Rate';
|
return 'Default Rate';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '--';
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const showEditProjectModal = ref(false);
|
const showEditProjectModal = ref(false);
|
||||||
@@ -69,6 +82,8 @@ const showEditProjectModal = ref(false);
|
|||||||
<ProjectEditModal
|
<ProjectEditModal
|
||||||
v-model:show="showEditProjectModal"
|
v-model:show="showEditProjectModal"
|
||||||
:original-project="project"></ProjectEditModal>
|
:original-project="project"></ProjectEditModal>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger as-child>
|
||||||
<TableRow :href="route('projects.show', { project: project.id })">
|
<TableRow :href="route('projects.show', { project: project.id })">
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
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">
|
||||||
@@ -83,13 +98,13 @@ const showEditProjectModal = ref(false);
|
|||||||
</span>
|
</span>
|
||||||
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
|
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
|
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
|
||||||
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
||||||
{{ client?.name }}
|
{{ client?.name }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else>No client</div>
|
<div v-else class="text-text-tertiary">No client</div>
|
||||||
</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-primary">
|
||||||
<div v-if="project.spent_time">
|
<div v-if="project.spent_time">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
@@ -99,23 +114,24 @@ const showEditProjectModal = ref(false);
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div v-else>--</div>
|
<div v-else class="text-text-tertiary">--</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
||||||
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
||||||
<EstimatedTimeProgress
|
<EstimatedTimeProgress
|
||||||
v-else-if="project.estimated_time"
|
v-else-if="project.estimated_time"
|
||||||
:estimated="project.estimated_time"
|
:estimated="project.estimated_time"
|
||||||
:current="project.spent_time"></EstimatedTimeProgress>
|
:current="project.spent_time"></EstimatedTimeProgress>
|
||||||
<span v-else> -- </span>
|
<span v-else class="text-text-tertiary"> -- </span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showBillableRate"
|
v-if="showBillableRate"
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||||
{{ billableRateInfo }}
|
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
|
||||||
|
<span v-else class="text-text-tertiary">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
|
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center font-medium">
|
||||||
<template v-if="project.is_archived">
|
<template v-if="project.is_archived">
|
||||||
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
|
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
|
||||||
<span>Archived</span>
|
<span>Archived</span>
|
||||||
@@ -134,6 +150,32 @@ const showEditProjectModal = ref(false);
|
|||||||
@delete="deleteProject"></ProjectMoreOptionsDropdown>
|
@delete="deleteProject"></ProjectMoreOptionsDropdown>
|
||||||
</div>
|
</div>
|
||||||
</TableRow>
|
</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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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: [];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import VChart, { THEME_KEY } from 'vue-echarts';
|
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||||
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
|
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
|
||||||
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
||||||
import { formatDate, formatHumanReadableDuration, formatWeek } from '@/packages/ui/src/utils/time';
|
import { formatDate, formatReportingDuration, formatWeek } from '@/packages/ui/src/utils/time';
|
||||||
import { use } from 'echarts/core';
|
import { use } from 'echarts/core';
|
||||||
import { CanvasRenderer } from 'echarts/renderers';
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
import { BarChart } from 'echarts/charts';
|
import { BarChart } from 'echarts/charts';
|
||||||
@@ -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]);
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ const option = computed(() => ({
|
|||||||
type: 'bar',
|
type: 'bar',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
valueFormatter: (value: number) => {
|
valueFormatter: (value: number) => {
|
||||||
return formatHumanReadableDuration(
|
return formatReportingDuration(
|
||||||
value,
|
value,
|
||||||
organization?.value?.interval_format,
|
organization?.value?.interval_format,
|
||||||
organization?.value?.number_format
|
organization?.value?.number_format
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { SaveIcon } from 'lucide-vue-next';
|
import { SaveIcon } from 'lucide-vue-next';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import {
|
import {
|
||||||
formatHumanReadableDuration,
|
formatReportingDuration,
|
||||||
getDayJsInstance,
|
getDayJsInstance,
|
||||||
getLocalizedDayJs,
|
getLocalizedDayJs,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
@@ -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';
|
||||||
@@ -426,7 +426,7 @@ const tableData = computed(() => {
|
|||||||
class="justify-end flex items-center font-medium"
|
class="justify-end flex items-center font-medium"
|
||||||
:class="!showBillableRate ? 'pr-6' : ''">
|
:class="!showBillableRate ? 'pr-6' : ''">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatReportingDuration(
|
||||||
aggregatedTableTimeEntries.seconds,
|
aggregatedTableTimeEntries.seconds,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
TitleComponent,
|
TitleComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
} from 'echarts/components';
|
} from 'echarts/components';
|
||||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
import { formatReportingDuration } 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]);
|
||||||
@@ -67,7 +67,7 @@ const option = computed(() => ({
|
|||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
valueFormatter: (value: number) => {
|
valueFormatter: (value: number) => {
|
||||||
return formatHumanReadableDuration(
|
return formatReportingDuration(
|
||||||
value,
|
value,
|
||||||
organization?.value?.interval_format,
|
organization?.value?.interval_format,
|
||||||
organization?.value?.number_format
|
organization?.value?.number_format
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
||||||
import { ref, inject, type ComputedRef } from 'vue';
|
import { ref, inject, type ComputedRef } from 'vue';
|
||||||
@@ -44,7 +44,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
|||||||
</div>
|
</div>
|
||||||
<div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''">
|
<div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatReportingDuration(
|
||||||
entry.seconds,
|
entry.seconds,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ 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-medium 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-medium">
|
||||||
{{ value ?? '--' }}
|
{{ value ?? '--' }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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: [];
|
||||||
|
|||||||
@@ -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,6 +27,8 @@ function deleteTag() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger as-child>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
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">
|
||||||
@@ -36,6 +46,25 @@ function deleteTag() {
|
|||||||
</div>
|
</div>
|
||||||
<TagEditModal v-model:show="showTagEditModal" :tag="tag"></TagEditModal>
|
<TagEditModal v-model:show="showTagEditModal" :tag="tag"></TagEditModal>
|
||||||
</TableRow>
|
</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>
|
||||||
|
|||||||
@@ -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: [];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ defineProps<{
|
|||||||
<div class="items-center justify-center flex-1 hidden @2xs:flex">
|
<div class="items-center justify-center flex-1 hidden @2xs:flex">
|
||||||
<DayOverviewCardChart :history="history"></DayOverviewCardChart>
|
<DayOverviewCardChart :history="history"></DayOverviewCardChart>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex text-sm items-center justify-center text-text-secondary min-w-[65px]">
|
<div class="flex text-sm items-center justify-center text-text-primary min-w-[65px]">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
duration,
|
duration,
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ async function startTaskTimer() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="px-3.5 py-2 grid grid-cols-5">
|
<div class="px-3.5 py-2 grid grid-cols-5">
|
||||||
<div class="col-span-4">
|
<div class="col-span-4">
|
||||||
<p class="text-text-secondary text-sm pb-1.5 truncate">
|
<p class="text-text-primary text-sm pb-1.5 truncate">
|
||||||
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
|
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
|
||||||
<span v-else>No description</span>
|
<span v-else class="text-text-secondary">No description</span>
|
||||||
</p>
|
</p>
|
||||||
<ProjectBadge size="base" class="min-w-0 max-w-full" :color="project?.color">
|
<ProjectBadge size="base" class="min-w-0 max-w-full" :color="project?.color">
|
||||||
<div class="flex items-center lg:space-x-0.5 min-w-0">
|
<div class="flex items-center lg:space-x-0.5 min-w-0">
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const { data: latestTeamActivity, isLoading } = useQuery({
|
|||||||
class="text-center flex flex-1 justify-center items-center">
|
class="text-center flex flex-1 justify-center items-center">
|
||||||
<div>
|
<div>
|
||||||
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
|
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
|
||||||
<h3 class="text-text-primary font-semibold text-sm">Invite your co-workers</h3>
|
<h3 class="text-text-primary font-medium text-sm">Invite your co-workers</h3>
|
||||||
<p class="pb-5 text-sm">You can invite your entire team.</p>
|
<p class="pb-5 text-sm">You can invite your entire team.</p>
|
||||||
<SecondaryButton @click="router.visit(route('members'))"
|
<SecondaryButton @click="router.visit(route('members'))"
|
||||||
>Go to Members
|
>Go to Members
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ defineProps<{
|
|||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<p
|
<p
|
||||||
class="text-xs min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-secondary">
|
class="text-sm font-medium min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</p>
|
</p>
|
||||||
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
||||||
@@ -20,11 +20,11 @@ defineProps<{
|
|||||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
|
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
|
||||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="text-green-500 font-medium text-sm block pb-0.5"> working </span>
|
<span class="text-green-500 text-sm block pb-0.5"> working </span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-text-secondary text-sm font-medium text-ellipsis whitespace-nowrap max-w-full overflow-hidden">
|
class="text-text-secondary text-sm text-ellipsis whitespace-nowrap max-w-full overflow-hidden">
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ import CardTitle from '@/packages/ui/src/CardTitle.vue';
|
|||||||
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
||||||
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
|
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
|
||||||
import ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTable.vue';
|
import ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTable.vue';
|
||||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
import { formatReportingDuration } 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';
|
||||||
@@ -223,7 +223,7 @@ const option = computed(() => {
|
|||||||
type: 'bar',
|
type: 'bar',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
valueFormatter: (value: number) => {
|
valueFormatter: (value: number) => {
|
||||||
return formatHumanReadableDuration(
|
return formatReportingDuration(
|
||||||
value,
|
value,
|
||||||
organization?.value?.interval_format,
|
organization?.value?.interval_format,
|
||||||
organization?.value?.number_format
|
organization?.value?.number_format
|
||||||
@@ -252,7 +252,7 @@ const option = computed(() => {
|
|||||||
title="Spent Time"
|
title="Spent Time"
|
||||||
:value="
|
:value="
|
||||||
totalWeeklyTime
|
totalWeeklyTime
|
||||||
? formatHumanReadableDuration(
|
? formatReportingDuration(
|
||||||
totalWeeklyTime,
|
totalWeeklyTime,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
@@ -263,7 +263,7 @@ const option = computed(() => {
|
|||||||
title="Billable Time"
|
title="Billable Time"
|
||||||
:value="
|
:value="
|
||||||
totalWeeklyBillableTime
|
totalWeeklyBillableTime
|
||||||
? formatHumanReadableDuration(
|
? formatReportingDuration(
|
||||||
totalWeeklyBillableTime,
|
totalWeeklyBillableTime,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||||
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
||||||
import {
|
import {
|
||||||
formatHumanReadableDuration,
|
formatReportingDuration,
|
||||||
getDayJsInstance,
|
getDayJsInstance,
|
||||||
getLocalizedDayJs,
|
getLocalizedDayJs,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
@@ -174,7 +174,7 @@ const showBillableRate = computed(() => {
|
|||||||
class="justify-end flex items-center font-medium"
|
class="justify-end flex items-center font-medium"
|
||||||
:class="!showBillableRate ? 'pr-6' : ''">
|
:class="!showBillableRate ? 'pr-6' : ''">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatReportingDuration(
|
||||||
aggregatedTableTimeEntries.seconds,
|
aggregatedTableTimeEntries.seconds,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
|
|||||||
<CollapsibleRoot v-else v-model:open="open"
|
<CollapsibleRoot v-else v-model:open="open"
|
||||||
><CollapsibleTrigger class="w-full group py-0.5">
|
><CollapsibleTrigger class="w-full group py-0.5">
|
||||||
<div
|
<div
|
||||||
class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-medium text-sm items-center justify-between">
|
class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-regular text-sm items-center justify-between">
|
||||||
<div class="flex items-center gap-x-2">
|
<div class="flex items-center gap-x-2">
|
||||||
<component
|
<component
|
||||||
:is="icon"
|
:is="icon"
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
46
resources/js/Components/Timesheet/RemoveRowDialog.vue
Normal file
46
resources/js/Components/Timesheet/RemoveRowDialog.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/Components/ui/alert-dialog';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
entryCount: number;
|
||||||
|
projectName: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'update:open', value: boolean): void;
|
||||||
|
(e: 'confirm'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialog :open="open" @update:open="$emit('update:open', $event)">
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove timesheet row?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will delete {{ entryCount }} time
|
||||||
|
{{ entryCount === 1 ? 'entry' : 'entries' }}
|
||||||
|
for "{{ projectName }}". This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
@click="$emit('confirm')">
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</template>
|
||||||
63
resources/js/Components/Timesheet/TimesheetCell.vue
Normal file
63
resources/js/Components/Timesheet/TimesheetCell.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/packages/ui/src/tooltip';
|
||||||
|
import type { TimesheetCell } from '@/utils/useTimesheetGrid';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
cell?: TimesheetCell;
|
||||||
|
dayIndex: number;
|
||||||
|
date: string;
|
||||||
|
isToday: boolean;
|
||||||
|
hasRunningEntry: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [newSeconds: number];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-testid="timesheet_cell"
|
||||||
|
class="flex items-center justify-center border-t border-default-background-separator"
|
||||||
|
:class="{ 'bg-default-background': isToday }">
|
||||||
|
<TooltipProvider v-if="hasRunningEntry" :delay-duration="100">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<span class="inline-block cursor-not-allowed">
|
||||||
|
<DurationSecondsInput
|
||||||
|
:model-value="cell?.totalSeconds ?? 0"
|
||||||
|
disabled
|
||||||
|
default-unit="hours"
|
||||||
|
placeholder="-"
|
||||||
|
size="sm"
|
||||||
|
input-class="w-[80px] mx-auto text-center font-medium
|
||||||
|
bg-transparent text-text-primary placeholder:text-text-quaternary
|
||||||
|
rounded-lg border border-input-border shadow-none
|
||||||
|
pointer-events-none
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<DurationSecondsInput
|
||||||
|
v-else
|
||||||
|
:model-value="cell?.totalSeconds ?? 0"
|
||||||
|
default-unit="hours"
|
||||||
|
placeholder="-"
|
||||||
|
size="sm"
|
||||||
|
input-class="w-[80px] mx-auto text-center font-medium
|
||||||
|
bg-transparent text-text-primary placeholder:text-text-quaternary
|
||||||
|
rounded-lg border border-input-border shadow-none
|
||||||
|
hover:bg-card-background
|
||||||
|
focus-visible:bg-tertiary focus-visible:border-transparent
|
||||||
|
focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||||
|
@commit="(seconds) => emit('update', seconds ?? 0)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
48
resources/js/Components/Timesheet/TimesheetFooterActions.vue
Normal file
48
resources/js/Components/Timesheet/TimesheetFooterActions.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/packages/ui/src/dropdown-menu';
|
||||||
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
|
import { ChevronDownIcon, ClockIcon, ListBulletIcon } from '@heroicons/vue/20/solid';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
busy: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'copy-rows'): void;
|
||||||
|
(e: 'copy-with-time'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-2 flex items-center pl-4 pr-4">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button variant="ghost" size="sm" :disabled="busy">
|
||||||
|
<LoadingSpinner v-if="busy" class="h-3.5 w-3.5 m-0" />
|
||||||
|
Copy last week
|
||||||
|
<ChevronDownIcon v-if="!busy" class="h-3.5 w-3.5 ml-1 text-icon-default" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" class="min-w-[220px]">
|
||||||
|
<DropdownMenuItem
|
||||||
|
class="flex items-center space-x-3 cursor-pointer"
|
||||||
|
@click="$emit('copy-rows')">
|
||||||
|
<ListBulletIcon class="w-5 text-icon-default" />
|
||||||
|
<span>Copy rows only</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
class="flex items-center space-x-3 cursor-pointer"
|
||||||
|
@click="$emit('copy-with-time')">
|
||||||
|
<ClockIcon class="w-5 text-icon-default" />
|
||||||
|
<span>Copy rows and time entries</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
164
resources/js/Components/Timesheet/TimesheetGrid.vue
Normal file
164
resources/js/Components/Timesheet/TimesheetGrid.vue
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { inject, type ComputedRef } from 'vue';
|
||||||
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
|
import { PlusIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import TimesheetRow from '@/Components/Timesheet/TimesheetRow.vue';
|
||||||
|
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||||
|
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||||
|
import type {
|
||||||
|
Client,
|
||||||
|
CreateClientBody,
|
||||||
|
CreateProjectBody,
|
||||||
|
Organization,
|
||||||
|
Project,
|
||||||
|
Tag,
|
||||||
|
Task,
|
||||||
|
} from '@/packages/api/src';
|
||||||
|
import type { TimesheetRow as TimesheetRowType, TimesheetRowKey } from '@/utils/useTimesheetGrid';
|
||||||
|
|
||||||
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
const dayjs = getDayJsInstance();
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
rows: TimesheetRowType[];
|
||||||
|
weekDays: string[];
|
||||||
|
todayDate: string;
|
||||||
|
dayTotals: number[];
|
||||||
|
weekTotalFormatted: string;
|
||||||
|
projects: Project[];
|
||||||
|
tasks: Task[];
|
||||||
|
clients: Client[];
|
||||||
|
tags: Tag[];
|
||||||
|
currency: string;
|
||||||
|
canCreateProject: boolean;
|
||||||
|
enableEstimatedTime: boolean;
|
||||||
|
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||||
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
|
formatDuration: (seconds: number) => string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'remove-row', key: TimesheetRowKey): void;
|
||||||
|
(e: 'cell-update', row: TimesheetRowType, dayIndex: number, seconds: number): void;
|
||||||
|
(
|
||||||
|
e: 'project-task-change',
|
||||||
|
row: TimesheetRowType,
|
||||||
|
projectId: string | null,
|
||||||
|
taskId: string | null
|
||||||
|
): void;
|
||||||
|
(e: 'billable-change', row: TimesheetRowType, billable: boolean): void;
|
||||||
|
(e: 'tags-change', row: TimesheetRowType, tags: string[]): void;
|
||||||
|
(e: 'add-row', projectId: string | null, taskId: string | null): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
||||||
|
<div class="inline-block min-w-full align-middle">
|
||||||
|
<div
|
||||||
|
class="grid min-w-full w-max border-y border-default-background-separator"
|
||||||
|
style="
|
||||||
|
grid-template-columns:
|
||||||
|
minmax(420px, 1fr) repeat(7, minmax(96px, 120px)) minmax(100px, auto)
|
||||||
|
40px;
|
||||||
|
">
|
||||||
|
<!-- Header row -->
|
||||||
|
<div
|
||||||
|
class="bg-background dark:bg-secondary pl-7 pr-3 py-1 text-xs text-text-tertiary md:sticky md:left-0 md:z-10">
|
||||||
|
Project
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="day in weekDays"
|
||||||
|
:key="day"
|
||||||
|
class="bg-background dark:bg-secondary px-2 py-1 text-center">
|
||||||
|
<div class="text-xs font-medium text-text-secondary">
|
||||||
|
{{ dayjs(day).format('ddd D') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="bg-background dark:bg-secondary pl-3 pr-3 py-1 text-right text-xs text-text-tertiary">
|
||||||
|
Total
|
||||||
|
</div>
|
||||||
|
<div class="bg-background dark:bg-secondary"></div>
|
||||||
|
|
||||||
|
<!-- Data rows -->
|
||||||
|
<TimesheetRow
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.key"
|
||||||
|
:row="row"
|
||||||
|
:week-days="weekDays"
|
||||||
|
:today-date="todayDate"
|
||||||
|
:projects="projects"
|
||||||
|
:tasks="tasks"
|
||||||
|
:clients="clients"
|
||||||
|
:tags="tags"
|
||||||
|
:currency="currency"
|
||||||
|
:can-create-project="canCreateProject"
|
||||||
|
:enable-estimated-time="enableEstimatedTime"
|
||||||
|
:create-project="createProject"
|
||||||
|
:create-client="createClient"
|
||||||
|
:create-tag="createTag"
|
||||||
|
:format-duration="formatDuration"
|
||||||
|
@remove-row="$emit('remove-row', $event)"
|
||||||
|
@cell-update="
|
||||||
|
(dayIndex, seconds) => $emit('cell-update', row, dayIndex, seconds)
|
||||||
|
"
|
||||||
|
@project-task-change="(pId, tId) => $emit('project-task-change', row, pId, tId)"
|
||||||
|
@billable-change="(billable) => $emit('billable-change', row, billable)"
|
||||||
|
@tags-change="(t) => $emit('tags-change', row, t)" />
|
||||||
|
|
||||||
|
<!-- Add row -->
|
||||||
|
<div
|
||||||
|
class="col-span-full flex items-center gap-2 border-t border-default-background-separator pl-4 pr-4 py-2">
|
||||||
|
<TimeTrackerProjectTaskDropdown
|
||||||
|
:project="null"
|
||||||
|
:task="null"
|
||||||
|
:projects="projects"
|
||||||
|
:tasks="tasks"
|
||||||
|
:clients="clients"
|
||||||
|
:currency="currency"
|
||||||
|
:can-create-project="canCreateProject"
|
||||||
|
:enable-estimated-time="enableEstimatedTime"
|
||||||
|
:create-project="createProject"
|
||||||
|
:create-client="createClient"
|
||||||
|
:organization-billable-rate="organization?.billable_rate ?? null"
|
||||||
|
:no-project-value="null"
|
||||||
|
@changed="(p, t) => emit('add-row', p, t)">
|
||||||
|
<template #trigger>
|
||||||
|
<Button variant="ghost" size="sm" class="text-text-secondary">
|
||||||
|
<PlusIcon class="h-4 w-4 mr-1 text-icon-default" />
|
||||||
|
Add row
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</TimeTrackerProjectTaskDropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Totals row -->
|
||||||
|
<div
|
||||||
|
class="border-t border-default-background-separator bg-background dark:bg-secondary pl-7 pr-3 py-1 text-xs text-text-tertiary md:sticky md:left-0 md:z-10">
|
||||||
|
Total
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(total, dayIndex) in dayTotals"
|
||||||
|
:key="dayIndex"
|
||||||
|
data-testid="timesheet_day_total"
|
||||||
|
:class="[
|
||||||
|
'flex items-center justify-center border-t border-default-background-separator bg-secondary px-2 py-1 text-xs font-medium',
|
||||||
|
weekDays[dayIndex] === todayDate
|
||||||
|
? 'text-text-primary'
|
||||||
|
: 'text-text-secondary',
|
||||||
|
]">
|
||||||
|
<span class="w-[80px] text-center">
|
||||||
|
{{ total > 0 ? formatDuration(total) : '-' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-end border-t border-default-background-separator bg-secondary pl-3 pr-3 py-1 text-xs font-semibold text-text-primary">
|
||||||
|
{{ weekTotalFormatted }}
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-default-background-separator bg-secondary"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
60
resources/js/Components/Timesheet/TimesheetHeader.vue
Normal file
60
resources/js/Components/Timesheet/TimesheetHeader.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@heroicons/vue/20/solid';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
isCurrentWeek: boolean;
|
||||||
|
weekNumber: number;
|
||||||
|
weekRangeDisplay: string;
|
||||||
|
weekTotalFormatted: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'previous'): void;
|
||||||
|
(e: 'next'): void;
|
||||||
|
(e: 'current'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
|
||||||
|
<!-- Left: Week navigation -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
data-testid="timesheet_prev_week"
|
||||||
|
@click="$emit('previous')">
|
||||||
|
<ChevronLeftIcon class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
data-testid="timesheet_week_display"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-primary hover:bg-card-background rounded-md transition"
|
||||||
|
@click="$emit('current')">
|
||||||
|
<CalendarIcon class="h-4 w-4 text-icon-default" />
|
||||||
|
<span v-if="isCurrentWeek">This week</span>
|
||||||
|
<span v-else>{{ weekRangeDisplay }}</span>
|
||||||
|
<span class="text-text-tertiary">· W{{ weekNumber }}</span>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
data-testid="timesheet_next_week"
|
||||||
|
@click="$emit('next')">
|
||||||
|
<ChevronRightIcon class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Week total -->
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span class="text-xs text-text-tertiary uppercase tracking-wider">Week Total</span>
|
||||||
|
<span
|
||||||
|
data-testid="timesheet_grand_total"
|
||||||
|
class="text-sm font-semibold text-text-primary">
|
||||||
|
{{ weekTotalFormatted }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
133
resources/js/Components/Timesheet/TimesheetRow.vue
Normal file
133
resources/js/Components/Timesheet/TimesheetRow.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, type ComputedRef } from 'vue';
|
||||||
|
import { XMarkIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import TimesheetCell from './TimesheetCell.vue';
|
||||||
|
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||||
|
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
|
||||||
|
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||||
|
import type {
|
||||||
|
CreateClientBody,
|
||||||
|
CreateProjectBody,
|
||||||
|
Project,
|
||||||
|
Task,
|
||||||
|
Client,
|
||||||
|
Tag,
|
||||||
|
Organization,
|
||||||
|
} from '@/packages/api/src';
|
||||||
|
import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid';
|
||||||
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
|
|
||||||
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
row: TimesheetRow;
|
||||||
|
weekDays: string[];
|
||||||
|
todayDate: string;
|
||||||
|
projects: Project[];
|
||||||
|
tasks: Task[];
|
||||||
|
clients: Client[];
|
||||||
|
tags: Tag[];
|
||||||
|
currency: string;
|
||||||
|
canCreateProject: boolean;
|
||||||
|
enableEstimatedTime: boolean;
|
||||||
|
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||||
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
|
formatDuration: (seconds: number) => string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
removeRow: [key: TimesheetRowKey];
|
||||||
|
cellUpdate: [dayIndex: number, newSeconds: number];
|
||||||
|
projectTaskChange: [projectId: string | null, taskId: string | null];
|
||||||
|
billableChange: [billable: boolean];
|
||||||
|
tagsChange: [tags: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const selectedProject = computed({
|
||||||
|
get: () => props.row.projectId,
|
||||||
|
set: (val) => emit('projectTaskChange', val, selectedTask.value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedTask = computed({
|
||||||
|
get: () => props.row.taskId,
|
||||||
|
set: (val) => emit('projectTaskChange', selectedProject.value, val),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowTotalFormatted = computed(() => props.formatDuration(props.row.totalSeconds));
|
||||||
|
|
||||||
|
function hasRunningEntry(dayIndex: number): boolean {
|
||||||
|
const cell = props.row.cells.get(dayIndex);
|
||||||
|
if (!cell) return false;
|
||||||
|
return cell.entries.some((e) => e.end === null);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div data-testid="timesheet_row" class="contents group">
|
||||||
|
<!-- Project/Task column -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1 border-t border-default-background-separator bg-default-background pl-4 pr-3 py-2 md:sticky md:left-0 md:z-10">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<TimeTrackerProjectTaskDropdown
|
||||||
|
v-model:project="selectedProject"
|
||||||
|
v-model:task="selectedTask"
|
||||||
|
:projects="projects"
|
||||||
|
:tasks="tasks"
|
||||||
|
:clients="clients"
|
||||||
|
:currency="currency"
|
||||||
|
:can-create-project="canCreateProject"
|
||||||
|
:enable-estimated-time="enableEstimatedTime"
|
||||||
|
:create-project="createProject"
|
||||||
|
:create-client="createClient"
|
||||||
|
:organization-billable-rate="organization?.billable_rate ?? null"
|
||||||
|
:no-project-value="null"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
|
||||||
|
<TimeEntryRowTagDropdown
|
||||||
|
:create-tag="createTag"
|
||||||
|
:tags="tags"
|
||||||
|
:model-value="row.tags"
|
||||||
|
@changed="emit('tagsChange', $event)" />
|
||||||
|
<BillableToggleButton
|
||||||
|
:model-value="row.billable"
|
||||||
|
size="small"
|
||||||
|
faded
|
||||||
|
@changed="emit('billableChange', $event)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Day cells -->
|
||||||
|
<TimesheetCell
|
||||||
|
v-for="(day, dayIndex) in weekDays"
|
||||||
|
:key="day"
|
||||||
|
:cell="row.cells.get(dayIndex)"
|
||||||
|
:day-index="dayIndex"
|
||||||
|
:date="day"
|
||||||
|
:is-today="day === todayDate"
|
||||||
|
:has-running-entry="hasRunningEntry(dayIndex)"
|
||||||
|
@update="(seconds) => emit('cellUpdate', dayIndex, seconds)" />
|
||||||
|
|
||||||
|
<!-- Row total -->
|
||||||
|
<div
|
||||||
|
data-testid="timesheet_row_total"
|
||||||
|
class="flex items-center justify-end border-t border-default-background-separator pl-3 pr-3 py-3 text-sm font-medium text-text-primary">
|
||||||
|
{{ rowTotalFormatted }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Remove action -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center border-t border-default-background-separator pr-4 py-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-6 w-6 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
@click="emit('removeRow', row.key)">
|
||||||
|
<XMarkIcon class="h-3.5 w-3.5 text-icon-default" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
|
TableCellsIcon,
|
||||||
} from '@heroicons/vue/20/solid';
|
} from '@heroicons/vue/20/solid';
|
||||||
import { PanelLeft } from 'lucide-vue-next';
|
import { PanelLeft } from 'lucide-vue-next';
|
||||||
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
||||||
@@ -135,7 +136,7 @@ const page = usePage<{
|
|||||||
? 'max-lg:translate-x-0 max-lg:shadow-xl'
|
? 'max-lg:translate-x-0 max-lg:shadow-xl'
|
||||||
: 'max-lg:-translate-x-full',
|
: 'max-lg:-translate-x-full',
|
||||||
]"
|
]"
|
||||||
class="flex-shrink-0 h-screen fixed w-[280px] px-2.5 py-4 hidden lg:flex flex-col justify-between bg-background border-r border-default-background-separator max-lg:z-50 max-lg:transition-transform max-lg:duration-200 max-lg:ease-in-out lg:w-[230px] 2xl:w-[250px] 2xl:px-3 lg:border-r-0"
|
class="flex-shrink-0 h-screen fixed w-[280px] px-2.5 py-4 hidden lg:flex flex-col justify-between bg-background border-r border-default-background-separator max-lg:z-50 max-lg:transition-transform max-lg:duration-200 max-lg:ease-in-out lg:w-[230px] lg:border-r-0"
|
||||||
:style="showSidebarMenu ? { display: 'flex' } : undefined">
|
:style="showSidebarMenu ? { display: 'flex' } : undefined">
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<div
|
<div
|
||||||
@@ -185,6 +186,11 @@ const page = usePage<{
|
|||||||
:icon="CalendarIcon"
|
:icon="CalendarIcon"
|
||||||
:current="route().current('calendar')"
|
:current="route().current('calendar')"
|
||||||
:href="route('calendar')"></NavigationSidebarItem>
|
:href="route('calendar')"></NavigationSidebarItem>
|
||||||
|
<NavigationSidebarItem
|
||||||
|
title="Timesheet"
|
||||||
|
:icon="TableCellsIcon"
|
||||||
|
:current="route().current('timesheet')"
|
||||||
|
:href="route('timesheet')"></NavigationSidebarItem>
|
||||||
<NavigationSidebarItem
|
<NavigationSidebarItem
|
||||||
title="Reporting"
|
title="Reporting"
|
||||||
:icon="ChartBarIcon"
|
:icon="ChartBarIcon"
|
||||||
@@ -308,7 +314,7 @@ const page = usePage<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 lg:ml-[230px] 2xl:ml-[250px] min-w-0">
|
<div class="flex-1 lg:ml-[230px] min-w-0">
|
||||||
<div
|
<div
|
||||||
class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator">
|
class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
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 type { Dayjs } from 'dayjs';
|
||||||
import { useQueryClient } from '@tanstack/vue-query';
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
import {
|
import {
|
||||||
type Client,
|
type Client,
|
||||||
@@ -11,6 +12,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,9 +23,33 @@ 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 calendarStart = ref<Date | undefined>(undefined);
|
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||||
const calendarEnd = ref<Date | undefined>(undefined);
|
const calendarStart = ref<Dayjs | undefined>(undefined);
|
||||||
|
const calendarEnd = ref<Dayjs | 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,
|
||||||
@@ -74,20 +100,24 @@ const { tags } = useTagsQuery();
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
function onDatesChange({ start, end }: { start: Date; end: Date }) {
|
function onDatesChange({ start, end }: { start: Dayjs; end: Dayjs }) {
|
||||||
calendarStart.value = start;
|
calendarStart.value = start;
|
||||||
calendarEnd.value = end;
|
calendarEnd.value = end;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +128,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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<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,
|
import { Checkbox } from '@/packages/ui/src';
|
||||||
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';
|
||||||
|
import { groupSimilarTimeEntriesSetting } from '@/utils/timeEntryGrouping';
|
||||||
|
|
||||||
const preferredColor = usePreferredColorScheme();
|
const preferredColor = usePreferredColorScheme();
|
||||||
</script>
|
</script>
|
||||||
@@ -21,6 +17,7 @@ const preferredColor = usePreferredColorScheme();
|
|||||||
<template #description> Choose how you want solidtime to look on your device </template>
|
<template #description> Choose how you want solidtime to look on your device </template>
|
||||||
|
|
||||||
<template #form>
|
<template #form>
|
||||||
|
<!-- Theme -->
|
||||||
<Field class="col-span-6 sm:col-span-4">
|
<Field class="col-span-6 sm:col-span-4">
|
||||||
<FieldLabel for="theme">Theme</FieldLabel>
|
<FieldLabel for="theme">Theme</FieldLabel>
|
||||||
<Select id="theme" v-model="themeSetting">
|
<Select id="theme" v-model="themeSetting">
|
||||||
@@ -37,6 +34,14 @@ const preferredColor = usePreferredColorScheme();
|
|||||||
System default: {{ preferredColor }}
|
System default: {{ preferredColor }}
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<!-- Group similar time entries -->
|
||||||
|
<Field class="col-span-6 sm:col-span-4" orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
id="group_similar_time_entries"
|
||||||
|
v-model:checked="groupSimilarTimeEntriesSetting" />
|
||||||
|
<FieldLabel for="group_similar_time_entries">Group similar time entries</FieldLabel>
|
||||||
|
</Field>
|
||||||
</template>
|
</template>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,8 +387,10 @@ 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"
|
||||||
|
is-report
|
||||||
show-date
|
show-date
|
||||||
show-member
|
show-member
|
||||||
:time-entry="entry"
|
:time-entry="entry"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
|||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import { ChartBarIcon } from '@heroicons/vue/20/solid';
|
import { ChartBarIcon } from '@heroicons/vue/20/solid';
|
||||||
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
|
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
|
||||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
|
||||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||||
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
|
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
@@ -231,7 +231,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="justify-end flex items-center font-medium">
|
<div class="justify-end flex items-center font-medium">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatReportingDuration(
|
||||||
aggregatedTableTimeEntries.seconds,
|
aggregatedTableTimeEntries.seconds,
|
||||||
reportIntervalFormat,
|
reportIntervalFormat,
|
||||||
reportNumberFormat
|
reportNumberFormat
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FormSection from '@/Components/FormSection.vue';
|
import FormSection from '@/Components/FormSection.vue';
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { Field, FieldLabel } from '@/packages/ui/src/field';
|
import { Field, FieldDescription, 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';
|
||||||
@@ -58,6 +52,12 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showsHhMmSsInReports = computed(
|
||||||
|
() =>
|
||||||
|
form.value.interval_format === 'hours-minutes' ||
|
||||||
|
form.value.interval_format === 'hours-minutes-colon-separated'
|
||||||
|
);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
mutation.mutate(form.value);
|
mutation.mutate(form.value);
|
||||||
}
|
}
|
||||||
@@ -155,6 +155,12 @@ async function submit() {
|
|||||||
>
|
>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<FieldDescription v-if="showsHhMmSsInReports">
|
||||||
|
Reports and totals shown next to cost use HH:MM:SS for this format, so the
|
||||||
|
duration reconciles with the billable amount down to the second. Everywhere else
|
||||||
|
(time tracker, calendar, entry rows) seconds are omitted and durations stay in
|
||||||
|
your chosen format.
|
||||||
|
</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useElementVisibility } from '@vueuse/core';
|
|||||||
import { ClockIcon } from '@heroicons/vue/20/solid';
|
import { ClockIcon } from '@heroicons/vue/20/solid';
|
||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
|
import { groupSimilarTimeEntriesSetting } from '@/utils/timeEntryGrouping';
|
||||||
import { useTasksQuery } from '@/utils/useTasksQuery';
|
import { useTasksQuery } from '@/utils/useTasksQuery';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
||||||
@@ -26,6 +27,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 +90,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 +120,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 +140,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
|
||||||
@@ -145,6 +152,7 @@ function deleteSelected() {
|
|||||||
:tasks="tasks"
|
:tasks="tasks"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
:time-entries="timeEntries"
|
:time-entries="timeEntries"
|
||||||
|
:group-similar-time-entries="groupSimilarTimeEntriesSetting"
|
||||||
:tags="tags"></TimeEntryGroupedTable>
|
:tags="tags"></TimeEntryGroupedTable>
|
||||||
<div v-if="isPending" class="flex justify-center items-center py-12">
|
<div v-if="isPending" class="flex justify-center items-center py-12">
|
||||||
<LoadingSpinner></LoadingSpinner>
|
<LoadingSpinner></LoadingSpinner>
|
||||||
@@ -157,7 +165,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>
|
||||||
|
|||||||
196
resources/js/Pages/Timesheet.vue
Normal file
196
resources/js/Pages/Timesheet.vue
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
|
import TimesheetHeader from '@/Components/Timesheet/TimesheetHeader.vue';
|
||||||
|
import TimesheetGrid from '@/Components/Timesheet/TimesheetGrid.vue';
|
||||||
|
import TimesheetFooterActions from '@/Components/Timesheet/TimesheetFooterActions.vue';
|
||||||
|
import RemoveRowDialog from '@/Components/Timesheet/RemoveRowDialog.vue';
|
||||||
|
import { useTimesheetQuery } from '@/utils/useTimesheetQuery';
|
||||||
|
import { useTimesheetGrid } from '@/utils/useTimesheetGrid';
|
||||||
|
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
||||||
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
|
import { useTasksQuery } from '@/utils/useTasksQuery';
|
||||||
|
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||||
|
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||||
|
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||||
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
|
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
||||||
|
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
|
||||||
|
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
|
||||||
|
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
|
||||||
|
import { useTimesheetRowDeletion } from '@/utils/timesheet/useTimesheetRowDeletion';
|
||||||
|
import { useCopyLastWeek } from '@/utils/timesheet/useCopyLastWeek';
|
||||||
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
|
import type { CreateClientBody, CreateProjectBody, Project, Client, Tag } from '@/packages/api/src';
|
||||||
|
|
||||||
|
// ── Week state ────────────────────────────────────────────────────
|
||||||
|
const {
|
||||||
|
weekStart,
|
||||||
|
weekEnd,
|
||||||
|
weekDays,
|
||||||
|
weekNumber,
|
||||||
|
isCurrentWeek,
|
||||||
|
todayDate,
|
||||||
|
goToPreviousWeek,
|
||||||
|
goToNextWeek,
|
||||||
|
goToCurrentWeek,
|
||||||
|
} = useTimesheetWeek();
|
||||||
|
|
||||||
|
// ── Data fetching ─────────────────────────────────────────────────
|
||||||
|
const { data, isPending } = useTimesheetQuery(weekStart, weekEnd);
|
||||||
|
const timeEntries = computed(() => data.value?.data ?? []);
|
||||||
|
|
||||||
|
const { projects } = useProjectsQuery();
|
||||||
|
const { tasks } = useTasksQuery();
|
||||||
|
const { clients } = useClientsQuery();
|
||||||
|
const { tags } = useTagsQuery();
|
||||||
|
const { now: currentTimerNow } = storeToRefs(useCurrentTimeEntryStore());
|
||||||
|
|
||||||
|
const mutations = useTimeEntriesMutations();
|
||||||
|
|
||||||
|
// ── Grid computation ──────────────────────────────────────────────
|
||||||
|
const { rows, dayTotals, grandTotal, addSlot, removeSlot, updateSlot, clearSlots } =
|
||||||
|
useTimesheetGrid(timeEntries, weekDays, projects, tasks, currentTimerNow);
|
||||||
|
|
||||||
|
// Wipe slots on week navigation so the new week starts fresh — the
|
||||||
|
// grid's watcher will reseed from the newly fetched entries.
|
||||||
|
watch(weekStart, () => clearSlots());
|
||||||
|
|
||||||
|
// ── Formatters ────────────────────────────────────────────────────
|
||||||
|
// Pull number/interval format off the org via its query rather than
|
||||||
|
// inject('organization'), which is undefined during the page's setup
|
||||||
|
// (AppLayout provides it later in the lifecycle).
|
||||||
|
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||||
|
const intervalFormat = computed(() => organization.value?.interval_format ?? 'hours-minutes');
|
||||||
|
const numberFormat = computed(() => organization.value?.number_format ?? 'point');
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (seconds === 0) return '-';
|
||||||
|
return formatHumanReadableDuration(seconds, intervalFormat.value, numberFormat.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekTotalFormatted = computed(() =>
|
||||||
|
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const weekRangeDisplay = computed(() => {
|
||||||
|
const start = weekStart.value;
|
||||||
|
const end = start.add(6, 'day');
|
||||||
|
return start.month() === end.month()
|
||||||
|
? `${start.format('MMM D')} - ${end.format('D')}`
|
||||||
|
: `${start.format('MMM D')} - ${end.format('MMM D')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Cell / row mutation handlers ──────────────────────────────────
|
||||||
|
const { handleCellUpdate } = useTimesheetCellMutations(weekDays, timeEntries, rows, removeSlot);
|
||||||
|
|
||||||
|
const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations(
|
||||||
|
mutations,
|
||||||
|
projects,
|
||||||
|
rows,
|
||||||
|
addSlot,
|
||||||
|
updateSlot,
|
||||||
|
removeSlot
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
showDeleteDialog,
|
||||||
|
deleteRowEntryCount,
|
||||||
|
deleteRowProjectName,
|
||||||
|
requestRemoveRow,
|
||||||
|
confirmDeleteRow,
|
||||||
|
} = useTimesheetRowDeletion(projects, mutations, removeSlot);
|
||||||
|
|
||||||
|
function handleRemoveRow(key: string) {
|
||||||
|
const row = rows.value.find((r) => r.key === key);
|
||||||
|
if (row) requestRemoveRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Copy last week ────────────────────────────────────────────────
|
||||||
|
const { isCopyingLastWeek, copyLastWeekRows, copyLastWeekWithTime } = useCopyLastWeek(
|
||||||
|
weekStart,
|
||||||
|
weekDays,
|
||||||
|
rows,
|
||||||
|
timeEntries,
|
||||||
|
addSlot
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Inline creation helpers (passed to TimesheetRow) ──────────────
|
||||||
|
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||||
|
return await useProjectsStore().createProject(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createClient(body: CreateClientBody): Promise<Client | undefined> {
|
||||||
|
return await useClientsStore().createClient(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTag(name: string): Promise<Tag | undefined> {
|
||||||
|
return await useTagsStore().createTag(name);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout title="Timesheet" data-testid="timesheet_view">
|
||||||
|
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
||||||
|
<TimesheetHeader
|
||||||
|
:is-current-week="isCurrentWeek"
|
||||||
|
:week-number="weekNumber"
|
||||||
|
:week-range-display="weekRangeDisplay"
|
||||||
|
:week-total-formatted="weekTotalFormatted"
|
||||||
|
@previous="goToPreviousWeek"
|
||||||
|
@next="goToNextWeek"
|
||||||
|
@current="goToCurrentWeek" />
|
||||||
|
|
||||||
|
<TimesheetGrid
|
||||||
|
v-if="!isPending"
|
||||||
|
:rows="rows"
|
||||||
|
:week-days="weekDays"
|
||||||
|
:today-date="todayDate"
|
||||||
|
:day-totals="dayTotals"
|
||||||
|
:week-total-formatted="weekTotalFormatted"
|
||||||
|
:projects="projects"
|
||||||
|
:tasks="tasks"
|
||||||
|
:clients="clients"
|
||||||
|
:tags="tags"
|
||||||
|
:currency="getOrganizationCurrencyString()"
|
||||||
|
:can-create-project="canCreateProjects()"
|
||||||
|
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||||
|
:create-project="createProject"
|
||||||
|
:create-client="createClient"
|
||||||
|
:create-tag="createTag"
|
||||||
|
:format-duration="formatDuration"
|
||||||
|
@remove-row="handleRemoveRow"
|
||||||
|
@cell-update="handleCellUpdate"
|
||||||
|
@project-task-change="
|
||||||
|
(row, projectId, taskId) => handleRowIdentityChange(row, { projectId, taskId })
|
||||||
|
"
|
||||||
|
@billable-change="(row, billable) => handleRowIdentityChange(row, { billable })"
|
||||||
|
@tags-change="(row, tags) => handleRowIdentityChange(row, { tags })"
|
||||||
|
@add-row="handleAddRow" />
|
||||||
|
|
||||||
|
<TimesheetFooterActions
|
||||||
|
v-if="!isPending"
|
||||||
|
:busy="isCopyingLastWeek"
|
||||||
|
@copy-rows="copyLastWeekRows"
|
||||||
|
@copy-with-time="copyLastWeekWithTime" />
|
||||||
|
|
||||||
|
<div v-else class="flex justify-center items-center py-12">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RemoveRowDialog
|
||||||
|
v-model:open="showDeleteDialog"
|
||||||
|
:entry-count="deleteRowEntryCount"
|
||||||
|
:project-name="deleteRowProjectName"
|
||||||
|
@confirm="confirmDeleteRow" />
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@solidtime/ui",
|
"name": "@solidtime/ui",
|
||||||
"version": "0.0.16",
|
"version": "0.0.21",
|
||||||
"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",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const sizeClasses = {
|
|||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-text-primary rounded-lg font-semibold inline-flex items-center space-x-1.5 focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus:border-transparent disabled:opacity-25 ease-in-out',
|
'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-text-primary rounded-lg font-medium inline-flex items-center space-x-1.5 focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus:border-transparent disabled:opacity-25 ease-in-out',
|
||||||
sizeClasses[props.size],
|
sizeClasses[props.size],
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
|
|||||||
416
resources/js/packages/ui/src/FullCalendar/CalendarDayColumn.vue
Normal file
416
resources/js/packages/ui/src/FullCalendar/CalendarDayColumn.vue
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
<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';
|
||||||
|
|
||||||
|
const props = 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;
|
||||||
|
getTopActivity: (box: ActivityBox) => WindowActivityInPeriod | null;
|
||||||
|
isDayView: boolean;
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
showSelection: boolean;
|
||||||
|
isSelectionStart: boolean;
|
||||||
|
isSelectionIntermediate: boolean;
|
||||||
|
isSelectionEnd: boolean;
|
||||||
|
selectionTop: number;
|
||||||
|
selectionHeight: number;
|
||||||
|
selectionEndTop: number;
|
||||||
|
selectionEndHeight: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function isUncoveredByEvents(abox: ActivityBox): boolean {
|
||||||
|
return !props.dayEvents.some((de) => {
|
||||||
|
const eTop = de.top;
|
||||||
|
const eBottom = de.top + de.height;
|
||||||
|
const aTop = abox.top;
|
||||||
|
const aBottom = abox.top + abox.height;
|
||||||
|
return eTop < aBottom && eBottom > aTop;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
(e: 'activity-pointerdown', event: PointerEvent): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none"
|
||||||
|
:class="{
|
||||||
|
'has-activity-status': hasActivityStatus,
|
||||||
|
'activity-expanded': hasActivityStatus && isDayView,
|
||||||
|
}"
|
||||||
|
:data-date="dayStr"
|
||||||
|
:style="{ height: totalGridHeight + 'px' }">
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 left-0.5 right-0.5"
|
||||||
|
:class="{
|
||||||
|
'fc-events-inset': hasActivityStatus && !isDayView,
|
||||||
|
'fc-events-inset-expanded': hasActivityStatus && isDayView,
|
||||||
|
}">
|
||||||
|
<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 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="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 :disable-hoverable-content="true" :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',
|
||||||
|
{
|
||||||
|
'activity-status-box-expanded': isDayView,
|
||||||
|
'activity-status-box-uncovered':
|
||||||
|
!isDayView &&
|
||||||
|
!abox.isIdle &&
|
||||||
|
getTopActivity(abox) &&
|
||||||
|
isUncoveredByEvents(abox),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:style="{ top: abox.top + 'px', height: abox.height + 'px' }"
|
||||||
|
@pointerdown="emit('activity-pointerdown', $event)">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!abox.isIdle &&
|
||||||
|
getTopActivity(abox) &&
|
||||||
|
abox.height >= 16 &&
|
||||||
|
(isDayView || isUncoveredByEvents(abox))
|
||||||
|
"
|
||||||
|
class="activity-status-content">
|
||||||
|
<img
|
||||||
|
v-if="getTopActivity(abox)?.icon"
|
||||||
|
:src="getTopActivity(abox)!.icon!"
|
||||||
|
:alt="getTopActivity(abox)!.appName"
|
||||||
|
class="activity-status-icon" />
|
||||||
|
<div v-else class="activity-status-icon-fallback">
|
||||||
|
{{ getTopActivity(abox)!.appName.charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<span class="activity-status-label">
|
||||||
|
{{ getTopActivity(abox)!.label || getTopActivity(abox)!.appName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent :side="isDayView ? 'right' : 'left'" :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(14, 165, 233, 0.3);
|
||||||
|
}
|
||||||
|
.activity-status-box.active:hover::before {
|
||||||
|
background-color: rgba(14, 165, 233, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uncovered activity boxes in week view — fill column width */
|
||||||
|
.activity-status-box-uncovered {
|
||||||
|
width: calc(100% - 4px);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.activity-status-box-uncovered::before {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.activity-status-box-uncovered.active::before {
|
||||||
|
background-color: rgba(14, 165, 233, 0.12);
|
||||||
|
}
|
||||||
|
.activity-status-box-uncovered.active:hover::before {
|
||||||
|
background-color: rgba(14, 165, 233, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded activity boxes for day view */
|
||||||
|
.activity-status-box-expanded {
|
||||||
|
width: 200px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded::before {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded.idle::before {
|
||||||
|
background-color: rgba(156, 163, 175, 0.08);
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded.idle:hover::before {
|
||||||
|
background-color: rgba(156, 163, 175, 0.2);
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded.active::before {
|
||||||
|
background-color: rgba(14, 165, 233, 0.12);
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded.active:hover::before {
|
||||||
|
background-color: rgba(14, 165, 233, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status-icon-fallback {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: rgba(14, 165, 233, 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: rgba(14, 165, 233, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status-label {
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-events-inset {
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-events-inset-expanded {
|
||||||
|
left: 204px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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-default-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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user