Compare commits

...

19 Commits

Author SHA1 Message Date
Gregor Vostrak
2e08e61832 migrate daterange picker to shadcn component 2025-05-13 16:22:04 +02:00
Gregor Vostrak
dbf03fe515 improve time entry range design issue in 12-h format 2025-05-12 22:06:24 +02:00
Gregor Vostrak
361ccea472 add support for timeFormat in the frontend 2025-05-12 21:45:57 +02:00
Gregor Vostrak
67a06d3dbd add format options for number field component 2025-05-12 17:20:48 +02:00
Constantin Graf
fd4849c789 Add unit test for currency endpoint 2025-05-12 16:08:52 +02:00
Gregor Vostrak
4a9e3e3ca2 add e2e tests for organization format settings 2025-05-12 15:55:20 +02:00
Gregor Vostrak
9b1e181614 change e2e tests to use organization default values for money formatting 2025-05-12 15:55:20 +02:00
Gregor Vostrak
ff9672e155 fix shared report endpoint test to check new structure that includes organization format properties, format 2025-05-12 15:55:20 +02:00
Gregor Vostrak
77ff4d88be update api client, add api types, fix activitygraphcard formatting 2025-05-12 15:55:20 +02:00
Gregor Vostrak
a076bb2fb0 add frontend support for the date formatting option 2025-05-12 15:55:20 +02:00
Gregor Vostrak
f69bc28316 add frontend format support for currencies, add currencies endpoint 2025-05-12 15:55:20 +02:00
Gregor Vostrak
c1d43bcc67 add support for interval / duration format in frontend views 2025-05-12 15:55:20 +02:00
Constantin Graf
b8d9bc5b7e Fixed typos in organization format settings 2025-05-12 15:55:20 +02:00
Gregor Vostrak
f5b0f40b23 add formating options to organization settings 2025-05-12 15:55:20 +02:00
Constantin Graf
49af3d4371 Fixed missing time in pdf report 2025-05-07 22:13:27 +02:00
Gregor Vostrak
b4a6145f40 fix tanstack query store invalidation on detailed view update 2025-05-07 15:21:23 +02:00
Gregor Vostrak
06c6c874eb respect organization currency setting in shared report 2025-05-06 12:51:28 +02:00
Gregor Vostrak
b796d232f5 add reporting tests for detailed, project filter, billable filter, tag filter 2025-05-05 21:30:18 +02:00
Gregor Vostrak
26c50867b3 fix layout shift in shared reporting view 2025-05-01 12:35:51 +02:00
64 changed files with 2529 additions and 1104 deletions

View File

@@ -10,26 +10,26 @@ enum DateFormat: string
{
use LaravelEnumHelper;
case PointSeperatedDMYYYY = 'point-seperated-d-m-yyyy';
case SlashSeperatedMMDDYYYY = 'slash-seperated-mm-dd-yyyy';
case PointSeparatedDMYYYY = 'point-separated-d-m-yyyy';
case SlashSeparatedMMDDYYYY = 'slash-separated-mm-dd-yyyy';
case SlashSeperatedDDMMYYYY = 'slash-seperated-dd-mm-yyyy';
case SlashSeparatedDDMMYYYY = 'slash-separated-dd-mm-yyyy';
case HyphenSeperatedDDMMYYY = 'hyphen-seperated-dd-mm-yyyy';
case HyphenSeparatedDDMMYYY = 'hyphen-separated-dd-mm-yyyy';
case HyphenSeperatedMMDDDYYYY = 'hyphen-seperated-mm-dd-yyyy';
case HyphenSeparatedMMDDDYYYY = 'hyphen-separated-mm-dd-yyyy';
case HyphenSeperatedYYYYMMDD = 'hyphen-seperated-yyyy-mm-dd';
case HyphenSeparatedYYYYMMDD = 'hyphen-separated-yyyy-mm-dd';
public function toCarbonFormat(): string
{
return match ($this->value) {
self::PointSeperatedDMYYYY->value => 'j.n.Y',
self::SlashSeperatedMMDDYYYY->value => 'm/d/Y',
self::SlashSeperatedDDMMYYYY->value => 'd/m/Y',
self::HyphenSeperatedDDMMYYY->value => 'd-m-Y',
self::HyphenSeperatedMMDDDYYYY->value => 'm-d-Y',
self::HyphenSeperatedYYYYMMDD->value => 'Y-m-d',
self::PointSeparatedDMYYYY->value => 'j.n.Y',
self::SlashSeparatedMMDDYYYY->value => 'm/d/Y',
self::SlashSeparatedDDMMYYYY->value => 'd/m/Y',
self::HyphenSeparatedDDMMYYY->value => 'd-m-Y',
self::HyphenSeparatedMMDDDYYYY->value => 'm-d-Y',
self::HyphenSeparatedYYYYMMDD->value => 'Y-m-d',
};
}

View File

@@ -13,9 +13,9 @@ enum IntervalFormat: string
case Decimal = 'decimal';
case HoursMinutes = 'hours-minutes';
case HoursMinutesColonSeperated = 'hours-minutes-colon-seperated';
case HoursMinutesColonSeparated = 'hours-minutes-colon-separated';
case HoursMinutesSecondsColonSeperated = 'hours-minutes-seconds-colon-seperated';
case HoursMinutesSecondsColonSeparated = 'hours-minutes-seconds-colon-separated';
/**
* @return array<string, string>

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Service\CurrencyService;
use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\JsonResponse;
class CurrencyController extends Controller
{
/**
* Get all currencies
*
* @response array{code: string, name: string, symbol: string}[]
*
* @operationId getCurrencies
*/
public function index(): JsonResponse
{
$currencyService = app(CurrencyService::class);
$currencies = array_values(array_map(
fn (Currency $currency): array => [
'code' => $currency->getCurrencyCode(),
'name' => $currency->getName(),
'symbol' => $currencyService->getCurrencySymbol($currency->getCurrencyCode()),
],
ISOCurrencyProvider::getInstance()->getAvailableCurrencies()
));
return response()->json($currencies);
}
}

View File

@@ -4,8 +4,14 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Organization;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Resources\V1\BaseResource;
use App\Models\Organization;
use App\Service\CurrencyService;
use Illuminate\Http\Request;
/**
@@ -34,6 +40,8 @@ class OrganizationResource extends BaseResource
*/
public function toArray(Request $request): array
{
$currencyService = app(CurrencyService::class);
return [
/** @var string $id ID */
'id' => $this->resource->id,
@@ -47,15 +55,17 @@ class OrganizationResource extends BaseResource
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $number_format Number format */
/** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),
/** @var NumberFormat $number_format Number format */
'number_format' => $this->resource->number_format->value,
/** @var string $currency_format Currency format */
/** @var CurrencyFormat $currency_format Currency format */
'currency_format' => $this->resource->currency_format->value,
/** @var string $date_format Date format */
/** @var DateFormat $date_format Date format */
'date_format' => $this->resource->date_format->value,
/** @var string $interval_format Interval format */
/** @var IntervalFormat $interval_format Interval format */
'interval_format' => $this->resource->interval_format->value,
/** @var string $time_format Time format */
/** @var TimeFormat $time_format Time format */
'time_format' => $this->resource->time_format->value,
];
}

View File

@@ -4,8 +4,14 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Resources\V1\BaseResource;
use App\Models\Report;
use App\Service\CurrencyService;
use Illuminate\Http\Request;
/**
@@ -64,6 +70,8 @@ class DetailedWithDataReportResource extends BaseResource
*/
public function toArray(Request $request): array
{
$currencyService = app(CurrencyService::class);
return [
/** @var string $name Name */
'name' => $this->resource->name,
@@ -73,6 +81,18 @@ class DetailedWithDataReportResource extends BaseResource
'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->organization->currency,
/** @var NumberFormat $number_format Number format */
'number_format' => $this->resource->organization->number_format->value,
/** @var CurrencyFormat $currency_format Currency format */
'currency_format' => $this->resource->organization->currency_format->value,
/** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->organization->currency),
/** @var DateFormat $date_format Date format */
'date_format' => $this->resource->organization->date_format->value,
/** @var IntervalFormat $interval_format Interval format */
'interval_format' => $this->resource->organization->interval_format->value,
/** @var TimeFormat $time_format Time format */
'time_format' => $this->resource->organization->time_format->value,
'properties' => [
/** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value,

View File

@@ -85,11 +85,11 @@ class LocalizationService
$interval->cascade();
return ((int) floor($interval->totalHours)).'h '.$interval->format('%I').'m';
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutesColonSeperated) {
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutesColonSeparated) {
$interval->cascade();
return ((int) floor($interval->totalHours)).':'.$interval->format('%I');
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutesSecondsColonSeperated) {
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutesSecondsColonSeparated) {
$interval->cascade();
return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S');

View File

@@ -147,7 +147,7 @@ return [
'default_currency' => env('LOCALIZATION_DEFAULT_CURRENCY', 'EUR'),
'default_number_format' => env('LOCALIZATION_DEFAULT_NUMBER_FORMAT', NumberFormat::ThousandsPointDecimalComma->value),
'default_currency_format' => env('LOCALIZATION_DEFAULT_CURRENCY_FORMAT', CurrencyFormat::ISOCodeAfterWithSpace->value),
'default_date_format' => env('LOCALIZATION_DEFAULT_DATE_FORMAT', DateFormat::HyphenSeperatedYYYYMMDD->value),
'default_date_format' => env('LOCALIZATION_DEFAULT_DATE_FORMAT', DateFormat::HyphenSeparatedYYYYMMDD->value),
'default_time_format' => env('LOCALIZATION_DEFAULT_TIME_FORMAT', TimeFormat::TwentyFourHours->value),
'default_interval_format' => env('LOCALIZATION_DEFAULT_INTERVAL_FORMAT', IntervalFormat::HoursMinutes->value),
],

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// date_format
DB::statement("update organizations set date_format = 'point-separated-d-m-yyyy' where date_format = 'point-seperated-d-m-yyyy'");
DB::statement("update organizations set date_format = 'slash-separated-mm-dd-yyyy' where date_format = 'slash-seperated-mm-dd-yyyy'");
DB::statement("update organizations set date_format = 'slash-separated-dd-mm-yyyy' where date_format = 'slash-seperated-dd-mm-yyyy'");
DB::statement("update organizations set date_format = 'hyphen-separated-dd-mm-yyyy'where date_format = 'hyphen-seperated-dd-mm-yyyy'");
DB::statement("update organizations set date_format = 'hyphen-separated-mm-dd-yyyy' where date_format = 'hyphen-seperated-mm-dd-yyyy'");
DB::statement("update organizations set date_format = 'hyphen-separated-yyyy-mm-dd' where date_format = 'hyphen-seperated-yyyy-mm-dd'");
// interval_format
DB::statement("update organizations set interval_format = 'hours-minutes-colon-separated' where interval_format = 'hours-minutes-colon-seperated'");
DB::statement("update organizations set interval_format = 'hours-minutes-seconds-colon-separated' where interval_format = 'hours-minutes-seconds-colon-seperated'");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// date_format
DB::statement("update organizations set date_format = 'point-seperated-d-m-yyyy' where date_format = 'point-separated-d-m-yyyy'");
DB::statement("update organizations set date_format = 'slash-seperated-mm-dd-yyyy' where date_format = 'slash-separated-mm-dd-yyyy'");
DB::statement("update organizations set date_format = 'slash-seperated-dd-mm-yyyy' where date_format = 'slash-separated-dd-mm-yyyy'");
DB::statement("update organizations set date_format = 'hyphen-seperated-dd-mm-yyyy'where date_format = 'hyphen-separated-dd-mm-yyyy'");
DB::statement("update organizations set date_format = 'hyphen-seperated-mm-dd-yyyy' where date_format = 'hyphen-separated-mm-dd-yyyy'");
DB::statement("update organizations set date_format = 'hyphen-seperated-yyyy-mm-dd' where date_format = 'hyphen-separated-yyyy-mm-dd'");
// interval_format
DB::statement("update organizations set interval_format = 'hours-minutes-colon-seperated' where interval_format = 'hours-minutes-colon-separated'");
DB::statement("update organizations set interval_format = 'hours-minutes-seconds-colon-seperated' where interval_format = 'hours-minutes-seconds-colon-separated'");
}
};

View File

@@ -7,6 +7,29 @@ async function goToOrganizationSettings(page) {
await page.getByText('Organization Settings').click();
}
async function createTimeEntry(page, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill('Test time entry');
// Set duration
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
// Submit the time entry
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'POST' &&
response.status() === 201
),
]);
}
test('test that organization name can be updated', async ({ page }) => {
await goToOrganizationSettings(page);
await page.getByLabel('Organization Name').fill('NEW ORG NAME');
@@ -27,9 +50,11 @@ test('test that organization billable rate can be updated with all existing time
.getByLabel('Organization Billable Rate')
.fill(newBillableRate.toString());
await page
.locator('button')
.filter({ hasText: /^Save$/ })
.locator('form')
.filter({ hasText: 'Organization Billable' })
.getByRole('button')
.click();
await Promise.all([
page
.getByRole('button', { name: 'Yes, update existing time entries' })
@@ -51,4 +76,165 @@ test('test that organization billable rate can be updated with all existing time
]);
});
// TODO: Add Test for import
test('test that organization format settings can be updated', async ({
page,
}) => {
await goToOrganizationSettings(page);
// Test number format
await page.getByLabel('Number Format').click();
await page.getByRole('option', { name: '1,111.11' }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Number Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.number_format === 'comma-point'
),
]);
// Test currency format
await page.getByLabel('Currency Format').click();
await page.getByRole('option', { name: '111 EUR' }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Currency Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.currency_format ===
'iso-code-after-with-space'
),
]);
// Test date format
await page.getByLabel('Date Format').click();
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Date Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.date_format ===
'slash-separated-dd-mm-yyyy'
),
]);
// Test time format
await page.getByLabel('Time Format').click();
await page.getByRole('option', { name: '24-hour clock' }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Time Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.time_format === '24-hours'
),
]);
// Test interval format
await page.getByLabel('Time Duration Format').click();
await page.getByRole('option', { name: '12:03', exact: true }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Time Duration Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.interval_format ===
'hours-minutes-colon-separated'
),
]);
});
test('test that format settings are reflected in the dashboard', async ({
page,
}) => {
// check that 0h 00min is displayed
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).toBeVisible();
// First set the format settings
await goToOrganizationSettings(page);
// Set number format to comma-point
await page.getByLabel('Number Format').click();
await page.getByRole('option', { name: '1,111.11' }).click();
// Set currency format to symbol-after
await page.getByLabel('Currency Format').click();
await page.getByRole('option', { name: '111€' }).click();
// Set interval format to hours-minutes-colon-separated
await page.getByLabel('Time Duration Format').click();
await page.getByRole('option', { name: '12:03', exact: true }).click();
// Set date format to DD/MM/YYYY
await page.getByLabel('Date Format').click();
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Time Duration Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.interval_format ===
'hours-minutes-colon-separated' &&
(await response.json()).data.currency_format ===
'symbol-after' &&
(await response.json()).data.number_format === 'comma-point'
),
]);
await createTimeEntry(page, '00:00');
// Go to dashboard and check the formats
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
// Check billable amount format (number and currency)
await expect(page.getByText('0.00€')).toBeVisible();
// check that 00:00 is displayed
await expect(page.getByText('0:00', { exact: true }).nth(0)).toBeVisible();
// check that 0h 00min is not displayed
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).not.toBeVisible();
// check that the current date is displayed in the dd/mm/yyyy format on the time page
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0)).toBeVisible();
});
// TODO: Test 12-hour clock format

View File

@@ -1,7 +1,9 @@
import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { formatCents } from '../resources/js/packages/ui/src/utils/money';
import { formatCentsWithOrganizationDefaults } from './utils/money';
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
import { NumberFormat } from '@/packages/ui/src/utils/number';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
@@ -61,6 +63,6 @@ test('test that updating project member billable rate works for existing time en
page
.getByRole('row')
.first()
.getByText(formatCents(newBillableRate * 100, 'EUR'))
.getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100))
).toBeVisible();
});

View File

@@ -1,7 +1,8 @@
import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { formatCents } from '../resources/js/packages/ui/src/utils/money';
import { formatCentsWithOrganizationDefaults } from './utils/money';
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
@@ -131,7 +132,7 @@ test('test that updating billable rate works with existing time entries', async
page
.getByRole('row')
.first()
.getByText(formatCents(newBillableRate * 100, 'EUR'))
.getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100))
).toBeVisible();
});

View File

@@ -1,5 +1,210 @@
// TODO: Test filter
import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
// TODO: Test date range
// TODO: Test grouping and sub-grouping
async function goToTimeOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
}
async function goToReporting(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
}
async function goToReportingDetailed(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed');
}
async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) {
// First create the project through the Projects page
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(projectName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
// Wait for the project to be created and visible in the list
await page.getByText(projectName).waitFor({ state: 'visible' });
// Then create the time entry
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
// Set duration
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
// Submit the time entry
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(response => response.url().includes('/time-entries') && response.status() === 201)
]);
}
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
// Add tag
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
// Set duration
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
// Submit the time entry
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createTimeEntryWithBillableStatus(page: Page, isBillable: boolean, duration: string) {
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
// Set billable status
await page.getByRole('button', { name: 'Non-Billable' }).click();
if (!isBillable) {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
}
// Set duration
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
// Submit the time entry
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
test('test that project filtering works in reporting', async ({ page }) => {
const project1 = 'Test Project 1 ' + Math.floor(Math.random() * 10000);
const project2 = 'Test Project 2 ' + Math.floor(Math.random() * 10000);
// Create time entries for both projects
await createTimeEntryWithProject(page, project1, '1h');
await createTimeEntryWithProject(page, project2, '2h');
// Go to reporting and filter by project1
await goToReporting(page);
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(project1).click();
await Promise.all([
// escape
page.keyboard.press('Escape'),
// wait for API request to finish
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
]);
await page.waitForLoadState('networkidle');
// Verify only project1 time entries are shown
await expect(page.getByText(project1)).toBeVisible();
await expect(page.getByText(project2)).not.toBeVisible();
});
test('test that tag filtering works in reporting', async ({ page }) => {
const tag1 = 'Test Tag 1 ' + Math.floor(Math.random() * 10000);
const tag2 = 'Test Tag 2 ' + Math.floor(Math.random() * 10000);
// Create time entries with different tags
await createTimeEntryWithTag(page, tag1, '1h');
await createTimeEntryWithTag(page, tag2, '2h');
// Go to reporting and filter by tag1
await goToReporting(page);
// wait for all requests to finish
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText(tag1).click();
await Promise.all([
// escape
page.keyboard.press('Escape'),
// wait for API request to finish
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
]);
// Verify only time entries with tag1 are shown
await expect(page.getByText('1h 00min').first()).toBeVisible();
});
test('test that billable status filtering works in reporting', async ({ page }) => {
// Create billable and non-billable time entries
await createTimeEntryWithBillableStatus(page, true, '1h');
await createTimeEntryWithBillableStatus(page, false, '2h');
// Go to reporting and filter by billable
await goToReporting(page);
await page.getByRole('button', { name: 'Billable' }).click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await Promise.all([
// escape
page.keyboard.press('Escape'),
// wait for API request to finish
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
]);
await page.waitForLoadState('networkidle');
await expect(page.getByText('1h 00min').first()).toBeVisible();
});
test('test that detailed view shows time entries correctly', async ({ page }) => {
const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000);
// Create a time entry
await createTimeEntryWithProject(page, projectName, '1h');
// Go to detailed reporting view
await goToReportingDetailed(page);
// Verify the time entry is shown with all details
await expect(page.getByText(projectName, { exact: true })).toBeVisible();
await expect(page.locator('input[name="Duration"]')).toHaveValue('1h 00min');
await expect(page.getByText('Time entry for ' + projectName, { exact: true })).toBeVisible();
});
test('test that updating duration in detailed view works correctly', async ({ page }) => {
const projectName = 'Duration Update Project ' + Math.floor(Math.random() * 10000);
const initialDuration = '1h';
const updatedDuration = '2h 30min';
// Create a time entry with initial duration
await createTimeEntryWithProject(page, projectName, initialDuration);
// Go to detailed reporting view
await goToReportingDetailed(page);
// Find and update the duration
const durationInput = page.locator('input[name="Duration"]').first();
await durationInput.click();
await durationInput.fill(updatedDuration);
await durationInput.press('Enter');
// Wait for the update to be processed
await page.waitForLoadState('networkidle');
// Verify the new duration is displayed
await expect(durationInput).toHaveValue(updatedDuration);
});
// TODO: test that date range filtering works in reporting

View File

@@ -218,9 +218,7 @@ test('test that updating a the duration in the overview works on blur', async ({
const newTimeEntry = timeEntryRows.first();
await assertThatTimeEntryRowIsStopped(newTimeEntry);
await page.waitForTimeout(1500);
const timeEntryDurationInput = newTimeEntry.getByTestId(
'time_entry_duration_input'
);
const timeEntryDurationInput = newTimeEntry.locator('input[name="Duration"]');
await timeEntryDurationInput.fill('20min');
await Promise.all([
@@ -238,9 +236,7 @@ test('test that updating a the duration in the overview works on blur', async ({
timeEntryDurationInput.press('Tab'),
]);
await expect(
newTimeEntry.getByTestId('time_entry_duration_input')
).toHaveValue('0h 20min');
await expect(timeEntryDurationInput).toHaveValue('0h 20min');
});
// Test that start stop button stops running timer

17
e2e/utils/money.ts Normal file
View File

@@ -0,0 +1,17 @@
import { formatCents } from '../../resources/js/packages/ui/src/utils/money';
import type { CurrencyFormat } from '../../resources/js/packages/ui/src/utils/money';
import { NumberFormat } from '../../resources/js/packages/ui/src/utils/number';
export function formatCentsWithOrganizationDefaults(
cents: number,
currencyCode: string = 'EUR',
currencySymbol: string = '€'
): string {
return formatCents(
cents,
currencyCode,
'iso-code-after-with-space' as CurrencyFormat,
currencySymbol,
'point-comma' as NumberFormat
);
}

View File

@@ -30,12 +30,12 @@ return [
],
'date_format' => [
DateFormat::PointSeperatedDMYYYY->value => 'D.M.YYYY',
DateFormat::SlashSeperatedMMDDYYYY->value => 'MM/DD/YYYY',
DateFormat::SlashSeperatedDDMMYYYY->value => 'DD/MM/YYYY',
DateFormat::HyphenSeperatedDDMMYYY->value => 'DD-MM-YYYY',
DateFormat::HyphenSeperatedMMDDDYYYY->value => 'MM-DD-YYYY',
DateFormat::HyphenSeperatedYYYYMMDD->value => 'YYYY-MM-DD',
DateFormat::PointSeparatedDMYYYY->value => 'D.M.YYYY',
DateFormat::SlashSeparatedMMDDYYYY->value => 'MM/DD/YYYY',
DateFormat::SlashSeparatedDDMMYYYY->value => 'DD/MM/YYYY',
DateFormat::HyphenSeparatedDDMMYYY->value => 'DD-MM-YYYY',
DateFormat::HyphenSeparatedMMDDDYYYY->value => 'MM-DD-YYYY',
DateFormat::HyphenSeparatedYYYYMMDD->value => 'YYYY-MM-DD',
],
'time_format' => [
@@ -46,8 +46,8 @@ return [
'interval_format' => [
IntervalFormat::Decimal->value => 'Decimal',
IntervalFormat::HoursMinutes->value => '12h 3m',
IntervalFormat::HoursMinutesColonSeperated->value => '12:03',
IntervalFormat::HoursMinutesSecondsColonSeperated->value => '12:03:45',
IntervalFormat::HoursMinutesColonSeparated->value => '12:03',
IntervalFormat::HoursMinutesSecondsColonSeparated->value => '12:03:45',
],
'currency_format' => [

View File

@@ -2,10 +2,14 @@
import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{
newBillableRate?: number | null;
memberName: string;
@@ -28,7 +32,10 @@ defineEmits<{
newBillableRate
? formatCents(
newBillableRate,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: ' the default rate of the organization'
}}</strong

View File

@@ -1,26 +1,27 @@
<script setup lang="ts">
import type { Member } from '@/packages/api/src';
import type { Member, Organization } from '@/packages/api/src';
import { api } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid';
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
import TableRow from '@/Components/TableRow.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { canInvitePlaceholderMembers } from '@/utils/permissions';
import { useMembersStore } from '@/utils/useMembers';
import {computed, ref} from 'vue';
import { computed, type ComputedRef, inject, ref } from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import { formatCents } from '@/packages/ui/src/utils/money';
import MemberMergeModal from "@/Components/Common/Member/MemberMergeModal.vue";
import MemberMakePlaceholderModal from "@/Components/Common/Member/MemberMakePlaceholderModal.vue";
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import { formatCents } from '../../../packages/ui/src/utils/money';
const props = defineProps<{
member: Member;
}>();
const organization = inject<ComputedRef<Organization>>('organization');
const showEditMemberModal = ref(false);
const showMergeMemberModal = ref(false);
const showMakeMemberPlaceholderModal = ref(false);
@@ -35,15 +36,12 @@ async function invitePlaceholder(id: string) {
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.invitePlaceholder(
undefined,
{
params: {
organization: organizationId,
member: id,
},
}
),
api.invitePlaceholder(undefined, {
params: {
organization: organizationId,
member: id,
},
}),
'Member invited successfully',
'Error inviting member'
);
@@ -52,8 +50,7 @@ async function invitePlaceholder(id: string) {
const userHasValidMailAddress = computed(() => {
return !props.member.email.endsWith('@solidtime-import.test');
})
});
</script>
<template>
@@ -75,7 +72,10 @@ const userHasValidMailAddress = computed(() => {
member.billable_rate
? formatCents(
member.billable_rate,
getOrganizationCurrencyString()
organization?.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
@@ -101,21 +101,26 @@ const userHasValidMailAddress = computed(() => {
"
size="small"
@click="invitePlaceholder(member.id)"
>Invite</SecondaryButton
>
>Invite
</SecondaryButton>
<MemberMoreOptionsDropdown
:member="member"
@edit="showEditMemberModal = true"
@delete="removeMember"
@merge="showMergeMemberModal = true"
@make-placeholder="showMakeMemberPlaceholderModal = true"
></MemberMoreOptionsDropdown>
@make-placeholder="
showMakeMemberPlaceholderModal = true
"></MemberMoreOptionsDropdown>
</div>
<MemberEditModal
v-model:show="showEditMemberModal"
:member="member"></MemberEditModal>
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
<MemberMakePlaceholderModal v-model:show="showMakeMemberPlaceholderModal" :member="member"></MemberMakePlaceholderModal>
<MemberMergeModal
v-model:show="showMergeMemberModal"
:member="member"></MemberMergeModal>
<MemberMakePlaceholderModal
v-model:show="showMakeMemberPlaceholderModal"
:member="member"></MemberMakePlaceholderModal>
</TableRow>
</template>

View File

@@ -2,10 +2,14 @@
import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{
newBillableRate?: number | null;
}>();
@@ -27,7 +31,10 @@ defineEmits<{
newBillableRate
? formatCents(
newBillableRate,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: ' none.'
}}</strong

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/packages/api/src';
import { computed, ref } from 'vue';
import { computed, ref, inject, type ComputedRef } from 'vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
@@ -15,6 +15,7 @@ import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import type { Organization } from '@/packages/api/src';
const { clients } = storeToRefs(useClientsStore());
const { tasks } = storeToRefs(useTasksStore());
@@ -46,12 +47,17 @@ function archiveProject() {
});
}
const organization = inject<ComputedRef<Organization>>('organization');
const billableRateInfo = computed(() => {
if (props.project.is_billable) {
if (props.project.billable_rate) {
return formatCents(
props.project.billable_rate,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.value?.currency_format,
organization?.value?.currency_symbol,
organization?.value?.number_format
);
} else {
return 'Default Rate';
@@ -61,6 +67,7 @@ const billableRateInfo = computed(() => {
});
const showEditProjectModal = ref(false);
</script>
<template>
@@ -79,9 +86,12 @@ const showEditProjectModal = ref(false);
<span class="overflow-ellipsis overflow-hidden">
{{ project.name }}
</span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
<span class="text-text-secondary">
{{ projectTasksCount }} Tasks
</span>
</div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<div
class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<div
v-if="project.client_id"
class="overflow-ellipsis overflow-hidden">
@@ -91,7 +101,13 @@ const showEditProjectModal = ref(false);
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div v-if="project.spent_time">
{{ formatHumanReadableDuration(project.spent_time) }}
{{
formatHumanReadableDuration(
project.spent_time,
organization?.interval_format,
organization?.number_format
)
}}
</div>
<div v-else>--</div>
</div>

View File

@@ -2,10 +2,14 @@
import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{
newBillableRate?: number | null;
memberName?: string;
@@ -28,7 +32,10 @@ defineEmits<{
newBillableRate
? formatCents(
newBillableRate,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: ' the default rate of the project'
}}</strong

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ProjectMember } from '@/packages/api/src';
import { computed, ref } from 'vue';
import { computed, ref, inject, type ComputedRef } from 'vue';
import { storeToRefs } from 'pinia';
import TableRow from '@/Components/TableRow.vue';
import { useMembersStore } from '@/utils/useMembers';
@@ -10,10 +10,14 @@ import { formatCents } from '@/packages/ui/src/utils/money';
import { capitalizeFirstLetter } from '@/utils/format';
import ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import type { Organization } from '@/packages/api/src';
const props = defineProps<{
projectMember: ProjectMember;
}>();
const organization = inject<ComputedRef<Organization>>('organization');
function deleteProjectMember() {
useProjectMembersStore().deleteProjectMember(
props.projectMember.project_id,
@@ -51,7 +55,10 @@ const showEditModal = ref(false);
projectMember.billable_rate
? formatCents(
projectMember.billable_rate,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide } from 'vue';
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import {
formatDate,
@@ -16,7 +16,7 @@ import {
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import type { AggregatedTimeEntries } from '@/packages/api/src';
import type { AggregatedTimeEntries, Organization } from '@/packages/api/src';
import { useCssVar } from '@vueuse/core';
use([
@@ -30,6 +30,8 @@ use([
provide(THEME_KEY, 'dark');
const organization = inject<ComputedRef<Organization>>('organization');
const chart = shallowRef(null);
type GroupedData = AggregatedTimeEntries['grouped_data'];
const props = defineProps<{
@@ -41,7 +43,7 @@ const xAxisLabels = computed(() => {
if (props.groupedType === 'week') {
return props?.groupedData?.map((el) => formatWeek(el.key));
}
return props?.groupedData?.map((el) => formatDate(el.key ?? ''));
return props?.groupedData?.map((el) => formatDate(el.key ?? '', organization?.value?.date_format));
});
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
@@ -143,7 +145,11 @@ const option = computed(() => ({
type: 'bar',
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(value);
return formatHumanReadableDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format
);
},
},
},
@@ -155,6 +161,7 @@ const option = computed(() => ({
<div class="w-[calc(100%-1px)]">
<v-chart
v-if="groupedData && groupedData?.length > 0"
ref="chart"
:autoresize="true"
class="chart"
:option="option" />

View File

@@ -0,0 +1,504 @@
<script setup lang="ts">
import {
ChartBarIcon,
CheckCircleIcon,
TagIcon,
UserGroupIcon,
} from '@heroicons/vue/20/solid';
import { FolderIcon } from '@heroicons/vue/16/solid';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import {
formatHumanReadableDuration,
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import SelectDropdown from '../../../packages/ui/src/Input/SelectDropdown.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { computed, type ComputedRef, inject, onMounted, ref } from 'vue';
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { storeToRefs } from 'pinia';
import {
type AggregatedTimeEntriesQueryParams,
api,
type CreateReportBodyProperties,
type Organization,
} from '@/packages/api/src';
import {
getCurrentMembershipId,
getCurrentOrganizationId,
getCurrentRole,
} from '@/utils/useUser';
import { useTagsStore } from '@/utils/useTags';
import { useSessionStorage, useStorage } from '@vueuse/core';
import { useNotificationsStore } from '@/utils/notification';
import type { ExportFormat } from '@/types/reporting';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useProjectsStore } from '@/utils/useProjects';
const { handleApiRequestNotifications } = useNotificationsStore();
const startDate = useSessionStorage<string>(
'reporting-start-date',
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
);
const endDate = useSessionStorage<string>(
'reporting-end-date',
getLocalizedDayJs(getDayJsInstance()().format()).format()
);
const selectedTags = ref<string[]>([]);
const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]);
const billable = ref<'true' | 'false' | null>(null);
const group = useStorage<GroupingOption>('reporting-group', 'project');
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
const reportingStore = useReportingStore();
const { aggregatedGraphTimeEntries, aggregatedTableTimeEntries } =
storeToRefs(reportingStore);
const { groupByOptions } = reportingStore;
const organization = inject<ComputedRef<Organization>>('organization');
function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
let params: AggregatedTimeEntriesQueryParams = {
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
end: getLocalizedDayJs(endDate.value).endOf('day').utc().format(),
};
params = {
...params,
member_ids:
selectedMembers.value.length > 0
? selectedMembers.value
: undefined,
project_ids:
selectedProjects.value.length > 0
? selectedProjects.value
: undefined,
task_ids:
selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids:
selectedClients.value.length > 0
? selectedClients.value
: undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
};
return params;
}
function updateGraphReporting() {
const params = getFilterAttributes();
if (getCurrentRole() === 'employee') {
params.member_id = getCurrentMembershipId();
}
params.fill_gaps_in_time_groups = 'true';
params.group = getOptimalGroupingOption(startDate.value, endDate.value);
useReportingStore().fetchGraphReporting(params);
}
function updateTableReporting() {
const params = getFilterAttributes();
if (group.value === subGroup.value) {
const fallbackOption = groupByOptions.find(
(el) => el.value !== group.value
);
if (fallbackOption?.value) {
subGroup.value = fallbackOption.value;
}
}
if (getCurrentRole() === 'employee') {
params.member_id = getCurrentMembershipId();
}
params.group = group.value;
params.sub_group = subGroup.value;
useReportingStore().fetchTableReporting(params);
}
function updateReporting() {
updateGraphReporting();
updateTableReporting();
}
function getOptimalGroupingOption(
startDate: string,
endDate: string
): 'day' | 'week' | 'month' {
const diffInDays = getDayJsInstance()(endDate).diff(
getDayJsInstance()(startDate),
'd'
);
if (diffInDays <= 31) {
return 'day';
} else if (diffInDays <= 200) {
return 'week';
} else {
return 'month';
}
}
onMounted(() => {
updateGraphReporting();
updateTableReporting();
});
const { tags } = storeToRefs(useTagsStore());
async function createTag(tag: string) {
return await useTagsStore().createTag(tag);
}
const reportProperties = computed(() => {
return {
...getFilterAttributes(),
group: group.value,
sub_group: subGroup.value,
history_group: getOptimalGroupingOption(startDate.value, endDate.value),
} as CreateReportBodyProperties;
});
async function downloadExport(format: ExportFormat) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const response = await handleApiRequestNotifications(
() =>
api.exportAggregatedTimeEntries({
params: {
organization: organizationId,
},
queries: {
...getFilterAttributes(),
group: group.value,
sub_group: subGroup.value,
history_group: getOptimalGroupingOption(
startDate.value,
endDate.value
),
format: format,
},
}),
'Export successful',
'Export failed'
);
if (response?.download_url) {
showExportModal.value = true;
exportUrl.value = response.download_url as string;
}
}
}
const { getNameForReportingRowEntry, emptyPlaceholder } = useReportingStore();
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
const showExportModal = ref(false);
const exportUrl = ref<string | null>(null);
const groupedPieChartData = computed(() => {
return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
const name = getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
);
let color = getRandomColorWithSeed(entry.key ?? 'none');
if (
name &&
aggregatedTableTimeEntries.value?.grouped_type &&
emptyPlaceholder[
aggregatedTableTimeEntries.value?.grouped_type
] === name
) {
color = '#CCCCCC';
} else if (
aggregatedTableTimeEntries.value?.grouped_type === 'project'
) {
color =
projects.value?.find((project) => project.id === entry.key)
?.color ?? '#CCCCCC';
}
return {
value: entry.seconds,
name:
getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
) ?? '',
color: color,
};
}) ?? []
);
});
const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
seconds: entry.seconds,
cost: entry.cost,
description: getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
),
grouped_data:
entry.grouped_data?.map((el) => {
return {
seconds: el.seconds,
cost: el.cost,
description: getNameForReportingRowEntry(
el.key,
entry.grouped_type
),
};
}) ?? [],
};
});
});
</script>
<template>
<ReportingExportModal
v-model:show="showExportModal"
:export-url="exportUrl"></ReportingExportModal>
<MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
</div>
<div class="flex space-x-2">
<ReportingExportButton
:download="downloadExport"></ReportingExportButton>
<ReportSaveButton
:report-properties="reportProperties"></ReportSaveButton>
</div>
</MainContainer>
<div class="py-2.5 w-full border-b border-default-background-separator">
<MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between">
<div
class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-4">
<div class="text-sm font-medium">Filters</div>
<MemberMultiselectDropdown
v-model="selectedMembers"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedMembers.length"
:active="selectedMembers.length > 0"
title="Members"
:icon="UserGroupIcon"></ReportingFilterBadge>
</template>
</MemberMultiselectDropdown>
<ProjectMultiselectDropdown
v-model="selectedProjects"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedProjects.length"
:active="selectedProjects.length > 0"
title="Projects"
:icon="FolderIcon"></ReportingFilterBadge>
</template>
</ProjectMultiselectDropdown>
<TaskMultiselectDropdown
v-model="selectedTasks"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedTasks.length"
:active="selectedTasks.length > 0"
title="Tasks"
:icon="CheckCircleIcon"></ReportingFilterBadge>
</template>
</TaskMultiselectDropdown>
<ClientMultiselectDropdown
v-model="selectedClients"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedClients.length"
:active="selectedClients.length > 0"
title="Clients"
:icon="FolderIcon"></ReportingFilterBadge>
</template>
</ClientMultiselectDropdown>
<TagDropdown
v-model="selectedTags"
:create-tag
:tags="tags"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedTags.length"
:active="selectedTags.length > 0"
title="Tags"
:icon="TagIcon"></ReportingFilterBadge>
</template>
</TagDropdown>
<SelectDropdown
v-model="billable"
:get-key-from-item="(item) => item.value"
:get-name-for-item="(item) => item.label"
:items="[
{
label: 'Both',
value: null,
},
{
label: 'Billable',
value: 'true',
},
{
label: 'Non Billable',
value: 'false',
},
]"
@changed="updateReporting">
<template #trigger>
<ReportingFilterBadge
:active="billable !== null"
:title="
billable === 'false'
? 'Non Billable'
: 'Billable'
"
:icon="BillableIcon"></ReportingFilterBadge>
</template>
</SelectDropdown>
</div>
<div>
<DateRangePicker
v-model:start="startDate"
v-model:end="endDate"
@submit="updateReporting"></DateRangePicker>
</div>
</MainContainer>
</div>
<MainContainer>
<div class="pt-10 w-full px-3 relative">
<ReportingChart
:grouped-type="aggregatedGraphTimeEntries?.grouped_type"
:grouped-data="
aggregatedGraphTimeEntries?.grouped_data
"></ReportingChart>
</div>
</MainContainer>
<MainContainer>
<div class="sm:grid grid-cols-4 pt-6 items-start">
<div
class="col-span-3 bg-card-background rounded-lg border border-card-border pt-3">
<div
class="text-sm flex text-text-primary items-center space-x-3 font-medium px-6 border-b border-card-background-separator pb-3">
<span>Group by</span>
<ReportingGroupBySelect
v-model="group"
:group-by-options="groupByOptions"
@changed="
updateTableReporting
"></ReportingGroupBySelect>
<span>and</span>
<ReportingGroupBySelect
v-model="subGroup"
:group-by-options="
groupByOptions.filter((el) => el.value !== group)
"
@changed="
updateTableReporting
"></ReportingGroupBySelect>
</div>
<div
class="grid items-center"
style="grid-template-columns: 1fr 100px 150px">
<div
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-tertiary [&>*]:pb-1.5 [&>*]:pt-1 text-text-secondary text-sm">
<div class="pl-6">Name</div>
<div class="text-right">Duration</div>
<div class="text-right pr-6">Cost</div>
</div>
<template
v-if="
aggregatedTableTimeEntries?.grouped_data &&
aggregatedTableTimeEntries.grouped_data?.length > 0
">
<ReportingRow
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:currency="getOrganizationCurrencyString()"
:type="aggregatedTableTimeEntries.grouped_type"
:entry="entry"></ReportingRow>
<div
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
<div class="flex items-center pl-6 font-medium">
<span>Total</span>
</div>
<div
class="justify-end flex items-center font-medium">
{{
formatHumanReadableDuration(
aggregatedTableTimeEntries.seconds,
organization?.interval_format,
organization?.number_format
)
}}
</div>
<div
class="justify-end pr-6 flex items-center font-medium">
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</div>
</div>
</template>
<div
v-else
class="chart flex flex-col items-center justify-center py-12 col-span-3">
<p class="text-lg text-text-primary font-semibold">
No time entries found
</p>
<p>Try to change the filters and time range</p>
</div>
</div>
</div>
<div class="px-2 lg:px-4">
<ReportingPieChart
:data="groupedPieChartData"></ReportingPieChart>
</div>
</div>
</MainContainer>
</template>
<style scoped></style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide } from 'vue';
import { computed, provide, inject, type ComputedRef } from 'vue';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { PieChart } from 'echarts/charts';
@@ -11,7 +11,8 @@ import {
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVar } from "@vueuse/core";
import { useCssVar } from '@vueuse/core';
import type { Organization } from '@/packages/api/src';
use([
CanvasRenderer,
@@ -24,6 +25,8 @@ use([
provide(THEME_KEY, 'dark');
const organization = inject<ComputedRef<Organization>>('organization');
type ReportingChartDataEntry = {
value: number;
name: string;
@@ -71,7 +74,11 @@ const option = computed(() => ({
},
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(value);
return formatHumanReadableDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format
);
},
},
data: seriesData.value,

View File

@@ -2,9 +2,9 @@
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import { ref } from 'vue';
import { ref, inject, type ComputedRef } from 'vue';
import { twMerge } from 'tailwind-merge';
import { getOrganizationCurrencyString } from '@/utils/money';
import type { Organization } from '@/packages/api/src';
type AggregatedGroupedData = GroupedData & {
grouped_data?: GroupedData[] | null;
@@ -19,9 +19,12 @@ type GroupedData = {
const props = defineProps<{
entry: AggregatedGroupedData;
indent?: boolean;
currency: string;
}>();
const expanded = ref(false);
const organization = inject<ComputedRef<Organization>>('organization');
</script>
<template>
@@ -45,10 +48,22 @@ const expanded = ref(false);
</span>
</div>
<div class="justify-end flex items-center">
{{ formatHumanReadableDuration(entry.seconds) }}
{{
formatHumanReadableDuration(
entry.seconds,
organization?.interval_format,
organization?.number_format
)
}}
</div>
<div class="justify-end pr-6 flex items-center">
{{entry.cost ? formatCents(entry.cost, getOrganizationCurrencyString()) : '--' }}
{{ entry.cost ? formatCents(
entry.cost,
props.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
) : '--' }}
</div>
</div>
<div
@@ -58,6 +73,7 @@ const expanded = ref(false);
<ReportingRow
v-for="subEntry in entry.grouped_data"
:key="subEntry.description ?? 'none'"
:currency="props.currency"
indent
:entry="subEntry"></ReportingRow>
</div>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
defineProps<{
title: string;
value: string;
value?: string;
}>();
</script>
@@ -10,7 +10,7 @@ defineProps<{
class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-semibold text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-2xl text-text-primary pt-1 font-semibold">
{{ value }}
{{ value ?? '--' }}
</dd>
</div>
</template>

View File

@@ -6,16 +6,19 @@ import TaskMoreOptionsDropdown from '@/Components/Common/Task/TaskMoreOptionsDro
import TableRow from '@/Components/TableRow.vue';
import { canDeleteTasks } from '@/utils/permissions';
import TaskEditModal from '@/Components/Common/Task/TaskEditModal.vue';
import { ref } from 'vue';
import { ref, inject, type ComputedRef } from 'vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
import type { Organization } from '@/packages/api/src';
const props = defineProps<{
task: Task;
}>();
const organization = inject<ComputedRef<Organization>>('organization');
function deleteTask() {
useTasksStore().deleteTask(props.task.id);
}
@@ -41,7 +44,13 @@ const showTaskEditModal = ref(false);
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<span v-if="task.spent_time">
{{ formatHumanReadableDuration(task.spent_time) }}
{{
formatHumanReadableDuration(
task.spent_time,
organization?.interval_format,
organization?.number_format
)
}}
</span>
<span v-else> -- </span>
</div>

View File

@@ -3,9 +3,10 @@ import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import dayjs from 'dayjs';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatDuration } from '@/packages/ui/src/utils/time';
import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
import { getCurrentOrganizationId } from '@/utils/useUser';
const store = useCurrentTimeEntryStore();
const { currentTimeEntry, now, isActive } = storeToRefs(store);
const { setActiveState } = store;
@@ -14,10 +15,9 @@ const currentTime = computed(() => {
if (now.value && currentTimeEntry.value.start) {
const startTime = dayjs(currentTimeEntry.value.start);
const diff = now.value.diff(startTime, 's');
// return dayjs(diff).utc().format('HH:mm:ss');
return formatHumanReadableDuration(diff);
return formatDuration(diff);
}
return formatHumanReadableDuration(0);
return formatDuration(0);
});
const isRunningInDifferentOrganization = computed(() => {
@@ -43,7 +43,9 @@ const isRunningInDifferentOrganization = computed(() => {
</div>
</div>
<div>
<div class="text-text-secondary font-extrabold text-xs">Current Timer</div>
<div class="text-text-secondary font-extrabold text-xs">
Current Timer
</div>
<div class="text-text-primary font-medium text-lg">
{{ currentTime }}
</div>

View File

@@ -1,44 +1,45 @@
<script lang="ts" setup>
import VChart, { THEME_KEY } from "vue-echarts";
import { provide, computed } from "vue";
import { use } from "echarts/core";
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
import { BoltIcon } from "@heroicons/vue/20/solid";
import { HeatmapChart } from "echarts/charts";
import VChart, { THEME_KEY } from 'vue-echarts';
import { provide, computed, inject, type ComputedRef } from 'vue';
import { use } from 'echarts/core';
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import { BoltIcon } from '@heroicons/vue/20/solid';
import { HeatmapChart } from 'echarts/charts';
import {
CalendarComponent,
TitleComponent,
TooltipComponent,
VisualMapComponent
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import dayjs from "dayjs";
VisualMapComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import dayjs from 'dayjs';
import {
firstDayIndex,
formatDate,
formatHumanReadableDuration,
getDayJsInstance
} from "@/packages/ui/src/utils/time";
import { useCssVar } from "@vueuse/core";
import { useQuery } from "@tanstack/vue-query";
import { getCurrentOrganizationId } from "@/utils/useUser";
import { api } from "@/packages/api/src";
import { LoadingSpinner } from "@/packages/ui/src";
getDayJsInstance,
} from '@/packages/ui/src/utils/time';
import { useCssVar } from '@vueuse/core';
import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api, type Organization } from '@/packages/api/src';
import { LoadingSpinner } from '@/packages/ui/src';
const organization = inject<ComputedRef<Organization>>('organization');
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
const { data: dailyHoursTracked, isLoading } = useQuery({
queryKey: ["dailyTrackedHours", organizationId],
queryKey: ['dailyTrackedHours', organizationId],
queryFn: () => {
return api.dailyTrackedHours({
params: {
organization: organizationId.value!
}
organization: organizationId.value!,
},
});
},
enabled: computed(() => !!organizationId.value)
enabled: computed(() => !!organizationId.value),
});
use([
@@ -47,96 +48,105 @@ use([
VisualMapComponent,
CalendarComponent,
HeatmapChart,
CanvasRenderer
CanvasRenderer,
]);
provide(THEME_KEY, "dark");
provide(THEME_KEY, 'dark');
const max = computed(() => {
if (!isLoading.value && dailyHoursTracked.value) {
return Math.max(
Math.max(...dailyHoursTracked.value.map((el) => el.duration)),
1
);
} else {
return 1;
}
if (!isLoading.value && dailyHoursTracked.value) {
return Math.max(
Math.max(...dailyHoursTracked.value.map((el) => el.duration)),
1
);
} else {
return 1;
}
);
});
const backgroundColor = useCssVar('--color-card-background', null, { observe: true });
const itemBackgroundColor = useCssVar('--color-bg-tertiary', null, { observe: true });
const backgroundColor = useCssVar('--color-card-background', null, {
observe: true,
});
const itemBackgroundColor = useCssVar('--color-bg-tertiary', null, {
observe: true,
});
const option = computed(() => {
return {
tooltip: {},
visualMap: {
min: 0,
max: max.value,
type: "piecewise",
orient: "horizontal",
left: "center",
top: "center",
inRange: {
color: [itemBackgroundColor.value, "#2DBE45"]
},
show: false
return {
tooltip: {},
visualMap: {
min: 0,
max: max.value,
type: 'piecewise',
orient: 'horizontal',
left: 'center',
top: 'center',
inRange: {
color: [itemBackgroundColor.value, '#2DBE45'],
},
calendar: {
top: 40,
bottom: 20,
left: 40,
right: 10,
cellSize: [40, 40],
dayLabel: {
firstDay: firstDayIndex.value
},
splitLine: {
show: false
},
range: [
dayjs().format("YYYY-MM-DD"),
getDayJsInstance()()
.subtract(50, "day")
.startOf("week")
.format("YYYY-MM-DD")
],
itemStyle: {
color: "transparent",
borderWidth: 8,
borderColor: backgroundColor.value
},
yearLabel: { show: false }
show: false,
},
calendar: {
top: 40,
bottom: 20,
left: 40,
right: 10,
cellSize: [40, 40],
dayLabel: {
firstDay: firstDayIndex.value,
},
series: {
type: "heatmap",
coordinateSystem: "calendar",
data: dailyHoursTracked?.value?.map((el) => [el.date, el.duration]) ?? [],
itemStyle: {
borderRadius: 5,
borderColor: "rgba(255,255,255,0.05)",
borderWidth: 1
},
tooltip: {
valueFormatter: (value: number, dataIndex: number) => {
if(dailyHoursTracked?.value){
return (
formatDate(dailyHoursTracked?.value[dataIndex].date) +
": " +
formatHumanReadableDuration(value)
);
}
else {
return "";
}
splitLine: {
show: false,
},
range: [
dayjs().format('YYYY-MM-DD'),
getDayJsInstance()()
.subtract(50, 'day')
.startOf('week')
.format('YYYY-MM-DD'),
],
itemStyle: {
color: 'transparent',
borderWidth: 8,
borderColor: backgroundColor.value,
},
yearLabel: { show: false },
},
series: {
type: 'heatmap',
coordinateSystem: 'calendar',
data:
dailyHoursTracked?.value?.map((el) => [el.date, el.duration]) ??
[],
itemStyle: {
borderRadius: 5,
borderColor: 'rgba(255,255,255,0.05)',
borderWidth: 1,
},
tooltip: {
valueFormatter: (value: number, dataIndex: number) => {
if (dailyHoursTracked?.value) {
return (
formatDate(
dailyHoursTracked?.value[dataIndex].date,
organization?.value?.date_format
) +
': ' +
formatHumanReadableDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format
)
);
} else {
return '';
}
}
},
},
backgroundColor: "transparent"
};
});
},
backgroundColor: 'transparent',
};
});
</script>
<template>

View File

@@ -1,15 +1,19 @@
<script setup lang="ts">
import DayOverviewCardChart from '@/Components/Dashboard/DayOverviewCardChart.vue';
import {
formatHumanReadableDate,
formatHumanReadableDuration,
} from '@/packages/ui/src/utils/time';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{
date: string;
duration: number;
history: number[];
}>();
import {
formatHumanReadableDate,
formatHumanReadableDuration,
} from '@/packages/ui/src/utils/time';
</script>
<template>
@@ -25,7 +29,13 @@ import {
</div>
<div
class="flex text-sm items-center justify-center text-text-secondary min-w-[65px] font-semibold">
{{ formatHumanReadableDuration(duration) }}
{{
formatHumanReadableDuration(
duration,
organization?.interval_format,
organization?.number_format
)
}}
</div>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import VChart, { THEME_KEY } from 'vue-echarts';
import { provide } from 'vue';
import { provide, inject, type ComputedRef } from 'vue';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { PieChart } from 'echarts/charts';
@@ -12,6 +12,7 @@ import {
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVar } from "@vueuse/core";
import type { Organization } from "@/packages/api/src";
use([
CanvasRenderer,
@@ -33,6 +34,8 @@ const props = defineProps<{
}[];
}>();
const organization = inject<ComputedRef<Organization>>('organization');
const seriesData = props.weeklyProjectOverview.map((el) => {
return {
...el,
@@ -69,7 +72,7 @@ const option = computed(() => ({
},
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(value);
return formatHumanReadableDuration(value, organization?.value?.interval_format, organization?.value?.number_format);
},
},
data: seriesData,

View File

@@ -1,23 +1,28 @@
<script setup lang="ts">
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { BarChart } from "echarts/charts";
import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from "echarts/components";
import VChart, { THEME_KEY } from "vue-echarts";
import { computed, provide } from "vue";
import StatCard from "@/Components/Common/StatCard.vue";
import { ClockIcon } from "@heroicons/vue/20/solid";
import CardTitle from "@/packages/ui/src/CardTitle.vue";
import LinearGradient from "zrender/lib/graphic/LinearGradient";
import ProjectsChartCard from "@/Components/Dashboard/ProjectsChartCard.vue";
import { formatHumanReadableDuration } from "@/packages/ui/src/utils/time";
import { formatCents } from "@/packages/ui/src/utils/money";
import { getWeekStart } from "@/packages/ui/src/utils/settings";
import { useCssVar } from "@vueuse/core";
import { getOrganizationCurrencyString } from "@/utils/money";
import { useQuery } from "@tanstack/vue-query";
import { getCurrentOrganizationId } from "@/utils/useUser";
import { api } from "@/packages/api/src";
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide, inject, type ComputedRef } from 'vue';
import StatCard from '@/Components/Common/StatCard.vue';
import { ClockIcon } from '@heroicons/vue/20/solid';
import CardTitle from '@/packages/ui/src/CardTitle.vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import { getWeekStart } from '@/packages/ui/src/utils/settings';
import { useCssVar } from '@vueuse/core';
import { getOrganizationCurrencyString } from '@/utils/money';
import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api, type Organization } from '@/packages/api/src';
use([
CanvasRenderer,
@@ -25,21 +30,21 @@ use([
TitleComponent,
GridComponent,
TooltipComponent,
LegendComponent
LegendComponent,
]);
provide(THEME_KEY, "dark");
provide(THEME_KEY, 'dark');
const weekdays = computed(() => {
const daysOrder = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const daysOrder = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const dayMapping: Record<string, string> = {
monday: "Mon",
tuesday: "Tue",
wednesday: "Wed",
thursday: "Thu",
friday: "Fri",
saturday: "Sat",
sunday: "Sun"
monday: 'Mon',
tuesday: 'Tue',
wednesday: 'Wed',
thursday: 'Thu',
friday: 'Fri',
saturday: 'Sat',
sunday: 'Sun',
};
if (dayMapping[getWeekStart()]) {
const customOrder = [];
@@ -53,78 +58,76 @@ const weekdays = computed(() => {
} else {
return daysOrder;
}
});
const accentColor = useCssVar("--theme-color-chart", null, { observe: true });
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
const organization = inject<ComputedRef<Organization>>('organization');
// Set up the queries
const { data: weeklyProjectOverview } = useQuery({
queryKey: ["weeklyProjectOverview", organizationId],
queryKey: ['weeklyProjectOverview', organizationId],
queryFn: () => {
return api.weeklyProjectOverview({
params: {
organization: organizationId.value!
}
organization: organizationId.value!,
},
});
},
enabled: computed(() => !!organizationId.value)
enabled: computed(() => !!organizationId.value),
});
const { data: totalWeeklyTime } = useQuery({
queryKey: ["totalWeeklyTime", organizationId],
queryKey: ['totalWeeklyTime', organizationId],
queryFn: () => {
return api.totalWeeklyTime({
params: {
organization: organizationId.value!
}
organization: organizationId.value!,
},
});
},
enabled: computed(() => !!organizationId.value)
enabled: computed(() => !!organizationId.value),
});
const { data: totalWeeklyBillableTime } = useQuery({
queryKey: ["totalWeeklyBillableTime", organizationId],
queryKey: ['totalWeeklyBillableTime', organizationId],
queryFn: () => {
return api.totalWeeklyBillableTime({
params: {
organization: organizationId.value!
}
organization: organizationId.value!,
},
});
},
enabled: computed(() => !!organizationId.value)
enabled: computed(() => !!organizationId.value),
});
const { data: totalWeeklyBillableAmount } = useQuery({
queryKey: ["totalWeeklyBillableAmount", organizationId],
queryKey: ['totalWeeklyBillableAmount', organizationId],
queryFn: () => {
return api.totalWeeklyBillableAmount({
params: {
organization: organizationId.value!
}
organization: organizationId.value!,
},
});
},
enabled: computed(() => !!organizationId.value)
enabled: computed(() => !!organizationId.value),
});
const { data: weeklyHistory } = useQuery({
queryKey: ["weeklyHistory", organizationId],
queryKey: ['weeklyHistory', organizationId],
queryFn: () => {
return api.weeklyHistory({
params: {
organization: organizationId.value!
}
organization: organizationId.value!,
},
});
},
enabled: computed(() => !!organizationId.value)
enabled: computed(() => !!organizationId.value),
});
const seriesData = computed(() => {
if (!weeklyHistory.value) {
return [];
@@ -137,101 +140,104 @@ const seriesData = computed(() => {
borderColor: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgba(" + accentColor.value + ",0.7)"
color: 'rgba(' + accentColor.value + ',0.7)',
},
{
offset: 1,
color: "rgba(" + accentColor.value + ",0.5)"
}
color: 'rgba(' + accentColor.value + ',0.5)',
},
]),
emphasis: {
color: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgba(" + accentColor.value + ",0.9)"
color: 'rgba(' + accentColor.value + ',0.9)',
},
{
offset: 1,
color: "rgba(" + accentColor.value + ",0.7)"
}
])
color: 'rgba(' + accentColor.value + ',0.7)',
},
]),
},
borderRadius: [12, 12, 0, 0],
color: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgba(" + accentColor.value + ",0.7)"
color: 'rgba(' + accentColor.value + ',0.7)',
},
{
offset: 1,
color: "rgba(" + accentColor.value + ",0.5)"
}
])
}
}
color: 'rgba(' + accentColor.value + ',0.5)',
},
]),
},
},
};
});
});
const markLineColor = useCssVar("--color-border-secondary", null, { observe: true });
const labelColor = useCssVar("--color-text-secondary", null, { observe: true });
const markLineColor = useCssVar('--color-border-secondary', null, {
observe: true,
});
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const option = computed(() => {
return {
tooltip: {
trigger: "item"
trigger: 'item',
},
grid: {
top: 0,
right: 0,
bottom: 50,
left: 0
left: 0,
},
backgroundColor: "transparent",
backgroundColor: 'transparent',
xAxis: {
type: "category",
type: 'category',
data: weekdays.value,
axisLine: {
lineStyle: {
color: "transparent" // Set desired color here
}
color: 'transparent', // Set desired color here
},
},
axisLabel: {
fontSize: 16,
fontWeight: 600,
margin: 24,
fontFamily: "Outfit, sans-serif",
color: labelColor.value
fontFamily: 'Outfit, sans-serif',
color: labelColor.value,
},
axisTick: {
lineStyle: {
color: "transparent" // Set desired color here
}
}
color: 'transparent', // Set desired color here
},
},
},
yAxis: {
type: "value",
type: 'value',
splitLine: {
lineStyle: {
color: markLineColor.value
}
}
color: markLineColor.value,
},
},
},
series: [
{
data: seriesData.value,
type: "bar",
type: 'bar',
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(value);
}
}
}
]
return formatHumanReadableDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format
);
},
},
},
],
};
});
</script>
<template>
@@ -244,28 +250,45 @@ const option = computed(() => {
:icon="ClockIcon"></CardTitle>
<v-chart
v-if="weeklyHistory"
:autoresize="true" class="chart" :option="option" />
:autoresize="true"
class="chart"
:option="option" />
</div>
<div class="space-y-6">
<StatCard
title="Spent Time"
:value="
totalWeeklyTime ?
formatHumanReadableDuration(totalWeeklyTime) : '--'" />
totalWeeklyTime
? formatHumanReadableDuration(
totalWeeklyTime,
organization?.interval_format,
organization?.number_format
)
: '--'
" />
<StatCard
title="Billable Time"
:value="
totalWeeklyBillableTime ?
formatHumanReadableDuration(totalWeeklyBillableTime) : '--'
totalWeeklyBillableTime
? formatHumanReadableDuration(
totalWeeklyBillableTime,
organization?.interval_format,
organization?.number_format
)
: '--'
" />
<StatCard
title="Billable Amount"
:value="
totalWeeklyBillableAmount ?
formatCents(
totalWeeklyBillableAmount.value,
getOrganizationCurrencyString()
) : '--'
totalWeeklyBillableAmount
? formatCents(
totalWeeklyBillableAmount.value,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
" />
<ProjectsChartCard
v-if="weeklyProjectOverview"

View File

@@ -9,7 +9,8 @@ import { Calendar } from '@/Components/ui/calendar';
import { CalendarIcon } from 'lucide-vue-next';
import { formatDateLocalized } from '@/packages/ui/src/utils/time';
import { parseDate } from '@internationalized/date';
import { computed } from 'vue';
import { computed, inject, type ComputedRef } from 'vue';
import { type Organization } from '@/packages/api/src';
const model = defineModel<string | null>();
const emit = defineEmits<{
@@ -27,6 +28,8 @@ const handleBlur = () => {
const date = computed(() => {
return model.value ? parseDate(model.value) : undefined;
});
const organization = inject<ComputedRef<Organization>>('organization');
</script>
<template>
@@ -41,7 +44,7 @@ const date = computed(() => {
]"
>
<CalendarIcon class="mr-2 h-4 w-4" />
{{ model ? formatDateLocalized(model) : 'Pick a date' }}
{{ model ? formatDateLocalized(model, organization?.date_format) : 'Pick a date' }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">

View File

@@ -2,22 +2,61 @@
import type { NumberFieldRootEmits, NumberFieldRootProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { NumberFieldRoot, useForwardPropsEmits } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
import { computed, type HTMLAttributes, inject, type ComputedRef } from 'vue'
import type { Organization } from '@/packages/api/src'
const props = defineProps<NumberFieldRootProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<NumberFieldRootProps & {
class?: HTMLAttributes['class']
formatOptions?: {
maximumFractionDigits?: number
minimumFractionDigits?: number
}
}>()
const emits = defineEmits<NumberFieldRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
const { class: _, formatOptions: __, ...delegated } = props
return delegated
})
const organization = inject<ComputedRef<Organization>>('organization')
const locale = computed(() => {
const format = organization?.value?.number_format || 'comma-point'
// space poin is not supported in reka-ui
switch (format) {
case 'point-comma':
return 'de-DE' // Uses point for thousands and comma for decimal
case 'comma-point':
return 'en-US' // Uses comma for thousands and point for decimal
case 'space-comma':
return 'sv-SE' // Uses space for thousands and comma for decimal
case 'apostrophe-point':
return 'de-CH' // Uses apostrophe for thousands and point for decimal
default:
return 'en-US'
}
})
const defaultFormatOptions = {
maximumFractionDigits: 2
}
const formatOptions = computed(() => ({
...defaultFormatOptions,
...props.formatOptions
}))
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<NumberFieldRoot v-bind="forwarded" :class="cn('grid gap-1.5', props.class)">
<NumberFieldRoot
v-bind="forwarded"
:locale="locale"
:format-options="formatOptions"
:class="cn('grid gap-1.5', props.class)">
<slot />
</NumberFieldRoot>
</template>

View File

@@ -15,20 +15,22 @@ import {
UserCircleIcon,
UserGroupIcon,
XMarkIcon,
DocumentTextIcon
DocumentTextIcon,
} from '@heroicons/vue/20/solid';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { onMounted, ref } from "vue";
import { computed, onMounted, provide, ref } from 'vue';
import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores, refreshStores } from '@/utils/init';
import {
canManageBilling,
canUpdateOrganization,
canViewClients, canViewInvoices,
canViewClients,
canViewInvoices,
canViewMembers,
canViewProjects, canViewReport,
canViewProjects,
canViewReport,
canViewTags,
} from '@/utils/permissions';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
@@ -37,7 +39,11 @@ import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
import { fetchToken, isTokenValid } from '@/utils/session';
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
import { useTheme } from "@/utils/theme";
import { useTheme } from '@/utils/theme';
import { useQuery } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
defineProps({
title: String,
@@ -45,9 +51,25 @@ defineProps({
const showSidebarMenu = ref(false);
const isUnloading = ref(false);
onMounted(async () => {
useTheme()
const { data: organization, isLoading: isOrganizationLoading } = useQuery({
queryKey: ['organization', getCurrentOrganizationId()],
queryFn: () =>
api.getOrganization({
params: {
organization: getCurrentOrganizationId()!,
},
}),
enabled: !!getCurrentOrganizationId(),
});
provide(
'organization',
computed(() => organization.value?.data)
);
onMounted(async () => {
useTheme();
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
if (window.initialDataLoaded !== true) {
window.initialDataLoaded = true;
@@ -77,7 +99,9 @@ const page = usePage<{
</script>
<template>
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
<div
v-bind="$attrs"
class="flex flex-wrap bg-background text-text-secondary">
<div
:class="{
'!flex bg-default-background w-full z-[9999999999]':
@@ -122,17 +146,17 @@ const page = usePage<{
{
title: 'Overview',
route: 'reporting',
show: true
show: true,
},
{
title: 'Detailed',
route: 'reporting.detailed',
show: true
show: true,
},
{
title: 'Shared',
route: 'reporting.shared',
show: canViewReport()
show: canViewReport(),
},
]"
:current="
@@ -183,7 +207,9 @@ const page = usePage<{
:current="route().current('tags')"
:href="route('tags')"></NavigationSidebarItem>
<NavigationSidebarItem
v-if="isInvoicingActivated() && canViewInvoices()"
v-if="
isInvoicingActivated() && canViewInvoices()
"
title="Invoices"
:icon="DocumentTextIcon"
:current="route().current('invoices')"
@@ -276,8 +302,12 @@ const page = usePage<{
<!-- Page Content -->
<main class="pb-28 flex-1">
<slot />
<div
v-if="isOrganizationLoading"
class="flex items-center justify-center h-screen">
<LoadingSpinner />
</div>
<slot v-else />
</main>
</div>
</div>

View File

@@ -241,25 +241,6 @@ const page = usePage<{
</select>
<InputError :message="form.errors.week_start" class="mt-2" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="week_start" value="Start of the week" />
<select
id="week_start"
v-model="form.week_start"
name="week_start"
required
class="mt-1 block w-full border-input-border bg-input-background text-text-primary focus:border-input-border-active rounded-md shadow-sm">
<option value="" disabled>Select a week day</option>
<option
v-for="(weekdayTranslated, weekdayKey) in $page.props
.weekdays"
:key="weekdayKey"
:value="weekdayKey">
{{ weekdayTranslated }}
</option>
</select>
<InputError :message="form.errors.week_start" class="mt-2" />
</div>
</template>
<template #actions>

View File

@@ -3,7 +3,7 @@ import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, inject, type ComputedRef } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import { storeToRefs } from 'pinia';
import {
@@ -33,9 +33,12 @@ import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
import { Badge } from '@/packages/ui/src';
import { formatCents } from '../packages/ui/src/utils/money';
import { getOrganizationCurrencyString } from '../utils/money';
import type { Organization } from '@/packages/api/src';
const { projects } = storeToRefs(useProjectsStore());
const organization = inject<ComputedRef<Organization>>('organization');
const project = computed(() => {
return (
projects.value.find(
@@ -112,7 +115,10 @@ const shownTasks = computed(() => {
{{
formatCents(
project?.billable_rate ?? 0,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
}}
/ h
@@ -145,15 +151,11 @@ const shownTasks = computed(() => {
<div
class="w-full items-center flex justify-between">
<div class="pl-6">
<TabBar
v-model="activeTab"
>
<TabBarItem
value="active"
<TabBar v-model="activeTab">
<TabBarItem value="active"
>Active
</TabBarItem>
<TabBarItem
value="done"
<TabBarItem value="done"
>Done
</TabBarItem>
</TabBar>
@@ -185,11 +187,11 @@ const shownTasks = computed(() => {
Add Member
</SecondaryButton>
<ProjectMemberCreateModal
v-model:show="
createProjectMember
"
v-model:show="createProjectMember"
:project-id="projectId"
:existing-members="projectMembers"></ProjectMemberCreateModal>
:existing-members="
projectMembers
"></ProjectMemberCreateModal>
</template>
</CardTitle>
<Card>

View File

@@ -1,277 +1,8 @@
<script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon } from '@heroicons/vue/16/solid';
import PageTitle from '@/Components/Common/PageTitle.vue';
import {
ChartBarIcon,
UserGroupIcon,
CheckCircleIcon,
TagIcon,
} from '@heroicons/vue/20/solid';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { computed, onMounted, ref } from 'vue';
import {
formatHumanReadableDuration,
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { storeToRefs } from 'pinia';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import {
type AggregatedTimeEntriesQueryParams,
type CreateReportBodyProperties,
api,
} from '@/packages/api/src';
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import {
getCurrentMembershipId,
getCurrentOrganizationId,
getCurrentRole,
} from '@/utils/useUser';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import { useTagsStore } from '@/utils/useTags';
import { formatCents } from '@/packages/ui/src/utils/money';
import { useSessionStorage, useStorage } from '@vueuse/core';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import { useNotificationsStore } from '@/utils/notification';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import type { ExportFormat } from '@/types/reporting';
import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
const { handleApiRequestNotifications } = useNotificationsStore();
import ReportingOverview from "@/Components/Common/Reporting/ReportingOverview.vue";
const startDate = useSessionStorage<string>(
'reporting-start-date',
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
);
const endDate = useSessionStorage<string>(
'reporting-end-date',
getLocalizedDayJs(getDayJsInstance()().format()).format()
);
const selectedTags = ref<string[]>([]);
const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]);
const billable = ref<'true' | 'false' | null>(null);
const group = useStorage<GroupingOption>('reporting-group', 'project');
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
const reportingStore = useReportingStore();
const { aggregatedGraphTimeEntries, aggregatedTableTimeEntries } =
storeToRefs(reportingStore);
const { groupByOptions } = reportingStore;
function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
let params: AggregatedTimeEntriesQueryParams = {
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
end: getLocalizedDayJs(endDate.value).endOf('day').utc().format(),
};
params = {
...params,
member_ids:
selectedMembers.value.length > 0
? selectedMembers.value
: undefined,
project_ids:
selectedProjects.value.length > 0
? selectedProjects.value
: undefined,
task_ids:
selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids:
selectedClients.value.length > 0
? selectedClients.value
: undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
};
return params;
}
function updateGraphReporting() {
const params = getFilterAttributes();
if (getCurrentRole() === 'employee') {
params.member_id = getCurrentMembershipId();
}
params.fill_gaps_in_time_groups = 'true';
params.group = getOptimalGroupingOption(startDate.value, endDate.value);
useReportingStore().fetchGraphReporting(params);
}
function updateTableReporting() {
const params = getFilterAttributes();
if (group.value === subGroup.value) {
const fallbackOption = groupByOptions.find(
(el) => el.value !== group.value
);
if (fallbackOption?.value) {
subGroup.value = fallbackOption.value;
}
}
if (getCurrentRole() === 'employee') {
params.member_id = getCurrentMembershipId();
}
params.group = group.value;
params.sub_group = subGroup.value;
useReportingStore().fetchTableReporting(params);
}
function updateReporting() {
updateGraphReporting();
updateTableReporting();
}
function getOptimalGroupingOption(
startDate: string,
endDate: string
): 'day' | 'week' | 'month' {
const diffInDays = getDayJsInstance()(endDate).diff(
getDayJsInstance()(startDate),
'd'
);
if (diffInDays <= 31) {
return 'day';
} else if (diffInDays <= 200) {
return 'week';
} else {
return 'month';
}
}
onMounted(() => {
updateGraphReporting();
updateTableReporting();
});
const { tags } = storeToRefs(useTagsStore());
async function createTag(tag: string) {
return await useTagsStore().createTag(tag);
}
const reportProperties = computed(() => {
return {
...getFilterAttributes(),
group: group.value,
sub_group: subGroup.value,
history_group: getOptimalGroupingOption(startDate.value, endDate.value),
} as CreateReportBodyProperties;
});
async function downloadExport(format: ExportFormat) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const response = await handleApiRequestNotifications(
() =>
api.exportAggregatedTimeEntries({
params: {
organization: organizationId,
},
queries: {
...getFilterAttributes(),
group: group.value,
sub_group: subGroup.value,
history_group: getOptimalGroupingOption(
startDate.value,
endDate.value
),
format: format,
},
}),
'Export successful',
'Export failed'
);
if (response?.download_url) {
showExportModal.value = true;
exportUrl.value = response.download_url as string;
}
}
}
const { getNameForReportingRowEntry, emptyPlaceholder } = useReportingStore();
import { useProjectsStore } from '@/utils/useProjects';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
const showExportModal = ref(false);
const exportUrl = ref<string | null>(null);
const groupedPieChartData = computed(() => {
return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
const name = getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
);
let color = getRandomColorWithSeed(entry.key ?? 'none');
if (
name &&
aggregatedTableTimeEntries.value?.grouped_type &&
emptyPlaceholder[
aggregatedTableTimeEntries.value?.grouped_type
] === name
) {
color = '#CCCCCC';
} else if (
aggregatedTableTimeEntries.value?.grouped_type === 'project'
) {
color =
projects.value?.find((project) => project.id === entry.key)
?.color ?? '#CCCCCC';
}
return {
value: entry.seconds,
name:
getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
) ?? '',
color: color,
};
}) ?? []
);
});
const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
seconds: entry.seconds,
cost: entry.cost,
description: getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
),
grouped_data:
entry.grouped_data?.map((el) => {
return {
seconds: el.seconds,
cost: el.cost,
description: getNameForReportingRowEntry(
el.key,
entry.grouped_type
),
};
}) ?? [],
};
});
});
</script>
<template>
@@ -279,217 +10,6 @@ const tableData = computed(() => {
title="Reporting"
data-testid="reporting_view"
class="overflow-hidden">
<ReportingExportModal
v-model:show="showExportModal"
:export-url="exportUrl"></ReportingExportModal>
<MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
</div>
<div class="flex space-x-2">
<ReportingExportButton
:download="downloadExport"></ReportingExportButton>
<ReportSaveButton
:report-properties="reportProperties"></ReportSaveButton>
</div>
</MainContainer>
<div class="py-2.5 w-full border-b border-default-background-separator">
<MainContainer
class="sm:flex space-y-4 sm:space-y-0 justify-between">
<div
class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-4">
<div class="text-sm font-medium">Filters</div>
<MemberMultiselectDropdown
v-model="selectedMembers"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedMembers.length"
:active="selectedMembers.length > 0"
title="Members"
:icon="UserGroupIcon"></ReportingFilterBadge>
</template>
</MemberMultiselectDropdown>
<ProjectMultiselectDropdown
v-model="selectedProjects"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedProjects.length"
:active="selectedProjects.length > 0"
title="Projects"
:icon="FolderIcon"></ReportingFilterBadge>
</template>
</ProjectMultiselectDropdown>
<TaskMultiselectDropdown
v-model="selectedTasks"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedTasks.length"
:active="selectedTasks.length > 0"
title="Tasks"
:icon="CheckCircleIcon"></ReportingFilterBadge>
</template>
</TaskMultiselectDropdown>
<ClientMultiselectDropdown
v-model="selectedClients"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedClients.length"
:active="selectedClients.length > 0"
title="Clients"
:icon="FolderIcon"></ReportingFilterBadge>
</template>
</ClientMultiselectDropdown>
<TagDropdown
v-model="selectedTags"
:create-tag
:tags="tags"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedTags.length"
:active="selectedTags.length > 0"
title="Tags"
:icon="TagIcon"></ReportingFilterBadge>
</template>
</TagDropdown>
<SelectDropdown
v-model="billable"
:get-key-from-item="(item) => item.value"
:get-name-for-item="(item) => item.label"
:items="[
{
label: 'Both',
value: null,
},
{
label: 'Billable',
value: 'true',
},
{
label: 'Non Billable',
value: 'false',
},
]"
@changed="updateReporting">
<template #trigger>
<ReportingFilterBadge
:active="billable !== null"
:title="
billable === 'false'
? 'Non Billable'
: 'Billable'
"
:icon="BillableIcon"></ReportingFilterBadge>
</template>
</SelectDropdown>
</div>
<div>
<DateRangePicker
v-model:start="startDate"
v-model:end="endDate"
@submit="updateReporting"></DateRangePicker>
</div>
</MainContainer>
</div>
<MainContainer>
<div class="pt-10 w-full px-3 relative">
<ReportingChart
:grouped-type="aggregatedGraphTimeEntries?.grouped_type"
:grouped-data="
aggregatedGraphTimeEntries?.grouped_data
"></ReportingChart>
</div>
</MainContainer>
<MainContainer>
<div class="sm:grid grid-cols-4 pt-6 items-start">
<div
class="col-span-3 bg-card-background rounded-lg border border-card-border pt-3">
<div
class="text-sm flex text-text-primary items-center space-x-3 font-medium px-6 border-b border-card-background-separator pb-3">
<span>Group by</span>
<ReportingGroupBySelect
v-model="group"
:group-by-options="groupByOptions"
@changed="updateTableReporting"></ReportingGroupBySelect>
<span>and</span>
<ReportingGroupBySelect
v-model="subGroup"
:group-by-options="
groupByOptions.filter(
(el) => el.value !== group
)
"
@changed="updateTableReporting"></ReportingGroupBySelect>
</div>
<div
class="grid items-center"
style="grid-template-columns: 1fr 100px 150px">
<div
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-tertiary [&>*]:pb-1.5 [&>*]:pt-1 text-text-secondary text-sm">
<div class="pl-6">Name</div>
<div class="text-right">Duration</div>
<div class="text-right pr-6">Cost</div>
</div>
<template
v-if="
aggregatedTableTimeEntries?.grouped_data &&
aggregatedTableTimeEntries.grouped_data
?.length > 0
">
<ReportingRow
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:entry="entry"
:type="
aggregatedTableTimeEntries.grouped_type
"></ReportingRow>
<div
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
<div class="flex items-center pl-6 font-medium">
<span>Total</span>
</div>
<div
class="justify-end flex items-center font-medium">
{{
formatHumanReadableDuration(
aggregatedTableTimeEntries.seconds
)
}}
</div>
<div
class="justify-end pr-6 flex items-center font-medium">
{{
aggregatedTableTimeEntries.cost ?
formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString()
) : '--'
}}
</div>
</div>
</template>
<div
v-else
class="chart flex flex-col items-center justify-center py-12 col-span-3">
<p class="text-lg text-text-primary font-semibold">
No time entries found
</p>
<p>Try to change the filters and time range</p>
</div>
</div>
</div>
<div class="px-2 lg:px-4">
<ReportingPieChart
:data="groupedPieChartData"></ReportingPieChart>
</div>
</div>
</MainContainer>
<ReportingOverview></ReportingOverview>
</AppLayout>
</template>

View File

@@ -75,7 +75,7 @@ watch(currentPage, () => {
data-testid="reporting_view"
class="overflow-hidden">
<MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
class="py-3 sm:py-5 min-h-[79px] border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="shared"></ReportingTabNavbar>

View File

@@ -5,16 +5,16 @@ import { ChartBarIcon } from '@heroicons/vue/20/solid';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { computed, onMounted, ref } from 'vue';
import type { CurrencyFormat } from '@/packages/ui/src/utils/money';
import { computed, onMounted, provide, ref } from 'vue';
import { useQuery } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useReportingStore } from '@/utils/useReporting';
import { Head } from '@inertiajs/vue3';
import { useTheme } from "@/utils/theme";
import { useTheme } from '@/utils/theme';
const sharedSecret = ref<string | null>(null);
@@ -41,6 +41,39 @@ onMounted(() => {
}
});
const reportCurrency = computed(() => {
if (sharedReportResponseData.value) {
return sharedReportResponseData.value?.currency;
}
return 'EUR';
});
const reportIntervalFormat = computed(() => {
return sharedReportResponseData.value?.interval_format;
});
const reportNumberFormat = computed(() => {
return sharedReportResponseData.value?.number_format;
});
const reportCurrencyFormat = computed(() => {
return (sharedReportResponseData.value?.currency_format ?? 'symbol-before') as CurrencyFormat;
});
const reportCurrencySymbol = computed(() => {
return sharedReportResponseData.value?.currency_symbol;
});
provide(
'organization',
computed(() => ({
'number_format': reportNumberFormat.value,
'interval_format': reportIntervalFormat.value,
'currency_format': reportCurrencyFormat.value,
'currency_symbol': reportCurrencySymbol.value,
}))
);
const aggregatedTableTimeEntries = computed(() => {
if (sharedReportResponseData.value) {
return sharedReportResponseData.value?.data;
@@ -132,15 +165,16 @@ const tableData = computed(() => {
});
const { groupByOptions } = useReportingStore();
function getGroupLabel(key: string) {
return groupByOptions.find((option) => {
return option.value === key;
})?.label;
}
onMounted(async () => {
useTheme();
})
});
</script>
<template>
@@ -193,10 +227,9 @@ onMounted(async () => {
<ReportingRow
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:entry="entry"
:type="
aggregatedTableTimeEntries.grouped_type
"></ReportingRow>
:currency="reportCurrency"
:currency-format="reportCurrencyFormat"
:entry="entry"></ReportingRow>
<div
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
<div class="flex items-center pl-6 font-medium">
@@ -206,7 +239,9 @@ onMounted(async () => {
class="justify-end flex items-center font-medium">
{{
formatHumanReadableDuration(
aggregatedTableTimeEntries.seconds
aggregatedTableTimeEntries.seconds,
reportIntervalFormat,
reportNumberFormat
)
}}
</div>
@@ -215,7 +250,9 @@ onMounted(async () => {
{{
formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString()
reportCurrency,
reportCurrencyFormat,
reportCurrencySymbol,
)
}}
</div>

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import type { UpdateOrganizationBody } from '@/packages/api/src';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import type {
DateFormat,
TimeFormat,
IntervalFormat,
} from '@/packages/ui/src/utils/time';
import type { CurrencyFormat } from '@/packages/ui/src/utils/money';
import type { NumberFormat } from '@/packages/ui/src/utils/number';
interface FormValues {
number_format: NumberFormat | undefined;
currency_format: CurrencyFormat | undefined;
date_format: DateFormat | undefined;
time_format: TimeFormat | undefined;
interval_format: IntervalFormat | undefined;
}
const store = useOrganizationStore();
const { fetchOrganization, updateOrganization } = store;
const { organization } = storeToRefs(store);
const queryClient = useQueryClient();
const form = ref<FormValues>({
number_format: undefined,
currency_format: undefined,
date_format: undefined,
time_format: undefined,
interval_format: undefined,
});
const mutation = useMutation({
mutationFn: (values: FormValues) =>
updateOrganization(values as UpdateOrganizationBody),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organization'] });
},
});
onMounted(async () => {
await fetchOrganization();
if (organization.value) {
form.value = {
number_format: organization.value.number_format as NumberFormat,
currency_format: organization.value
.currency_format as CurrencyFormat,
date_format: organization.value.date_format as DateFormat,
time_format: organization.value.time_format as TimeFormat,
interval_format: organization?.value
.interval_format as IntervalFormat,
};
}
});
async function submit() {
mutation.mutate(form.value);
}
</script>
<template>
<FormSection>
<template #title>Format Settings</template>
<template #description>
Configure the default format settings for the organization.
</template>
<template #form>
<!-- Number Format -->
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<InputLabel
for="numberFormat"
class="mb-2"
value="Number Format" />
<Select v-model="form.number_format">
<SelectTrigger id="numberFormat">
<SelectValue placeholder="Select number format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="point-comma"
>1.111,11</SelectItem
>
<SelectItem value="comma-point"
>1,111.11</SelectItem
>
<SelectItem value="space-comma"
>1 111,11</SelectItem
>
<SelectItem value="space-point"
>1 111.11</SelectItem
>
<SelectItem value="apostrophe-point"
>1'111.11</SelectItem
>
</SelectContent>
</Select>
</div>
</div>
<!-- Currency Format -->
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<InputLabel
for="currencyFormat"
class="mb-2"
value="Currency Format" />
<Select v-model="form.currency_format">
<SelectTrigger id="currencyFormat">
<SelectValue placeholder="Select currency format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="iso-code-before-with-space"
>EUR 111</SelectItem
>
<SelectItem value="iso-code-after-with-space"
>111 EUR</SelectItem
>
<SelectItem value="symbol-before">€111</SelectItem>
<SelectItem value="symbol-after">111€</SelectItem>
<SelectItem value="symbol-before-with-space"
>€ 111</SelectItem
>
<SelectItem value="symbol-after-with-space"
>111 €</SelectItem
>
</SelectContent>
</Select>
</div>
</div>
<!-- Date Format -->
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<InputLabel
for="dateFormat"
class="mb-2"
value="Date Format" />
<Select v-model="form.date_format">
<SelectTrigger id="dateFormat">
<SelectValue placeholder="Select date format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="point-separated-d-m-yyyy"
>D.M.YYYY</SelectItem
>
<SelectItem value="slash-separated-mm-dd-yyyy"
>MM/DD/YYYY</SelectItem
>
<SelectItem value="slash-separated-dd-mm-yyyy"
>DD/MM/YYYY</SelectItem
>
<SelectItem value="hyphen-separated-dd-mm-yyyy"
>DD-MM-YYYY</SelectItem
>
<SelectItem value="hyphen-separated-mm-dd-yyyy"
>MM-DD-YYYY</SelectItem
>
<SelectItem value="hyphen-separated-yyyy-mm-dd"
>YYYY-MM-DD</SelectItem
>
</SelectContent>
</Select>
</div>
</div>
<!-- Time Format -->
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<InputLabel
for="timeFormat"
class="mb-2"
value="Time Format" />
<Select v-model="form.time_format">
<SelectTrigger id="timeFormat">
<SelectValue placeholder="Select time format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="12-hours"
>12-hour clock</SelectItem
>
<SelectItem value="24-hours"
>24-hour clock</SelectItem
>
</SelectContent>
</Select>
</div>
</div>
<!-- Interval Format -->
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<InputLabel
for="intervalFormat"
class="mb-2"
value="Time Duration Format" />
<Select v-model="form.interval_format">
<SelectTrigger id="intervalFormat">
<SelectValue placeholder="Select interval format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="decimal">Decimal</SelectItem>
<SelectItem value="hours-minutes"
>12h 3m</SelectItem
>
<SelectItem value="hours-minutes-colon-separated"
>12:03</SelectItem
>
<SelectItem
value="hours-minutes-seconds-colon-separated"
>12:03:45</SelectItem
>
</SelectContent>
</Select>
</div>
</div>
</template>
<template #actions>
<PrimaryButton :disabled="mutation.isPending.value" @click="submit">
{{ mutation.isPending.value ? 'Saving...' : 'Save' }}
</PrimaryButton>
</template>
</FormSection>
</template>

View File

@@ -7,6 +7,7 @@ import type { Organization } from '@/types/models';
import type { Permissions, Role } from '@/types/jetstream';
import { canUpdateOrganization } from '@/utils/permissions';
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
defineProps<{
team: Organization;
@@ -33,6 +34,11 @@ defineProps<{
:team="team" />
<SectionBorder />
<OrganizationFormatSettings
v-if="canUpdateOrganization()"
:team="team" />
<SectionBorder />
<template
v-if="permissions.canDeleteTeam && !team.personal_team">
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />

View File

@@ -167,7 +167,10 @@ export type ApiTokenIndexResponse = ZodiosResponseByAlias<
'getApiTokens'
>;
export type CreateApiTokenBody = ZodiosBodyByAlias<SolidTimeApi, 'createApiToken'>;
export type CreateApiTokenBody = ZodiosBodyByAlias<
SolidTimeApi,
'createApiToken'
>;
export type ApiToken = ApiTokenIndexResponse['data'][0];
export type DetailedInvoiceResponse = ZodiosResponseByAlias<
@@ -175,6 +178,26 @@ export type DetailedInvoiceResponse = ZodiosResponseByAlias<
'getInvoice'
>;
export type InvoiceIndexEntry = ZodiosResponseByAlias<
SolidTimeApi,
'getInvoices'
>['data'][0];
export type UpdateInvoiceSettings = ZodiosBodyByAlias<
SolidTimeApi,
'updateInvoiceSettings'
>;
export type CreateInvoiceBody = ZodiosBodyByAlias<
SolidTimeApi,
'createInvoice'
>;
export type UpdateInvoiceBody = ZodiosBodyByAlias<
SolidTimeApi,
'updateInvoice'
>;
const api = createApiClient('/api', { validate: 'none' });
export { createApiClient, api };

View File

@@ -130,7 +130,7 @@ const InvoiceEntryResource = z
name: z.string(),
description: z.union([z.string(), z.null()]),
unit_price: z.number().int(),
quantity: z.string(),
quantity: z.number(),
order_index: z.number().int(),
created_at: z.union([z.string(), z.null()]),
updated_at: z.union([z.string(), z.null()]),
@@ -164,8 +164,8 @@ const DetailedInvoiceResource = z
paid_at: z.union([z.string(), z.null()]),
due_at: z.string(),
discount_type: z.string(),
discount_amount: z.string(),
tax_rate: z.string(),
discount_amount: z.number().int(),
tax_rate: z.number().int(),
status: z.string(),
currency: z.string(),
date: z.string(),
@@ -293,21 +293,6 @@ const MemberMergeIntoRequest = z
.object({ member_id: z.string() })
.partial()
.passthrough();
const OrganizationResource = z
.object({
id: z.string(),
name: z.string(),
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
currency: z.string(),
number_format: z.string(),
currency_format: z.string(),
date_format: z.string(),
interval_format: z.string(),
time_format: z.string(),
})
.passthrough();
const NumberFormat = z.enum([
'point-comma',
'comma-point',
@@ -324,20 +309,36 @@ const CurrencyFormat = z.enum([
'symbol-after-with-space',
]);
const DateFormat = z.enum([
'point-seperated-d-m-yyyy',
'slash-seperated-mm-dd-yyyy',
'slash-seperated-dd-mm-yyyy',
'hyphen-seperated-dd-mm-yyyy',
'hyphen-seperated-mm-dd-yyyy',
'hyphen-seperated-yyyy-mm-dd',
'point-separated-d-m-yyyy',
'slash-separated-mm-dd-yyyy',
'slash-separated-dd-mm-yyyy',
'hyphen-separated-dd-mm-yyyy',
'hyphen-separated-mm-dd-yyyy',
'hyphen-separated-yyyy-mm-dd',
]);
const IntervalFormat = z.enum([
'decimal',
'hours-minutes',
'hours-minutes-colon-seperated',
'hours-minutes-seconds-colon-seperated',
'hours-minutes-colon-separated',
'hours-minutes-seconds-colon-separated',
]);
const TimeFormat = z.enum(['12-hours', '24-hours']);
const OrganizationResource = z
.object({
id: z.string(),
name: z.string(),
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
currency: z.string(),
currency_symbol: z.string(),
number_format: NumberFormat,
currency_format: CurrencyFormat,
date_format: DateFormat,
interval_format: IntervalFormat,
time_format: TimeFormat,
})
.passthrough();
const OrganizationUpdateRequest = z
.object({
name: z.string().max(255),
@@ -524,6 +525,12 @@ const DetailedWithDataReportResource = z
description: z.union([z.string(), z.null()]),
public_until: z.union([z.string(), z.null()]),
currency: z.string(),
number_format: NumberFormat,
currency_format: CurrencyFormat,
currency_symbol: z.string(),
date_format: DateFormat,
interval_format: IntervalFormat,
time_format: TimeFormat,
properties: z
.object({
group: z.string(),
@@ -769,12 +776,12 @@ export const schemas = {
Role,
MemberUpdateRequest,
MemberMergeIntoRequest,
OrganizationResource,
NumberFormat,
CurrencyFormat,
DateFormat,
IntervalFormat,
TimeFormat,
OrganizationResource,
OrganizationUpdateRequest,
ProjectResource,
ProjectStoreRequest,
@@ -812,7 +819,9 @@ const endpoints = makeApi([
path: '/v1/countries',
alias: 'getCountries',
requestFormat: 'json',
response: z.string(),
response: z.array(
z.object({ code: z.string(), name: z.string() }).passthrough()
),
errors: [
{
status: 401,
@@ -821,6 +830,21 @@ const endpoints = makeApi([
},
],
},
{
method: 'get',
path: '/v1/currencies',
alias: 'getCurrencies',
requestFormat: 'json',
response: z.array(
z
.object({
code: z.string(),
name: z.string(),
symbol: z.string(),
})
.passthrough()
),
},
{
method: 'get',
path: '/v1/organizations/:organization',

View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import {
formatCents,
getOrganizationCurrencySymbol,
} from '@/packages/ui/src/utils/money';
import { ref, watch } from 'vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { ref, watch, inject, type ComputedRef } from 'vue';
import { useFocus } from '@vueuse/core';
import type { Organization } from '@/packages/api/src';
const props = defineProps<{
name: string;
@@ -13,6 +11,8 @@ const props = defineProps<{
currency: string;
}>();
const organization = inject<ComputedRef<Organization>>('organization');
const model = defineModel<number | null>({
default: null,
});
@@ -58,9 +58,15 @@ function updateRate(value: string) {
}
function formatValue(modelValue: number | null) {
const formattedValue = formatCents(modelValue ?? 0, props.currency);
const formattedValue = formatCents(
modelValue ?? 0,
props.currency,
organization?.value?.currency_format,
organization?.value?.currency_symbol,
organization?.value?.number_format
);
return formattedValue
.replace(getOrganizationCurrencySymbol(props.currency), '')
?.replace(organization?.value?.currency_symbol ?? '', '')
.trim();
}

View File

@@ -1,155 +1,259 @@
<script setup lang="ts">
import { CalendarIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import {
formatDateLocalized,
Popover,
PopoverContent,
PopoverTrigger,
} from '@/Components/ui/popover';
import { RangeCalendar } from '@/Components/ui/range-calendar';
import {
CalendarDate,
getLocalTimeZone,
} from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next';
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { ref } from 'vue';
import { formatDateLocalized } from '@/packages/ui/src/utils/time';
import { type Organization } from '@/packages/api/src';
const start = defineModel('start', { default: '' });
const end = defineModel('end', { default: '' });
const props = defineProps<{
start: string;
end: string;
}>();
const emit = defineEmits(['submit']);
const emit = defineEmits<{
(e: 'update:start', value: string): void;
(e: 'update:end', value: string): void;
(e: 'submit'): void;
}>();
interface CalendarDateRange {
start: CalendarDate | undefined;
end: CalendarDate | undefined;
}
const today = computed(() => {
const now = getDayJsInstance()();
return new CalendarDate(now.year(), now.month() + 1, now.date());
});
const modelValue = computed<CalendarDateRange>({
get: () => ({
start: props.start
? new CalendarDate(
getLocalizedDayJs(props.start).year(),
getLocalizedDayJs(props.start).month() + 1,
getLocalizedDayJs(props.start).date()
)
: undefined,
end: props.end
? new CalendarDate(
getLocalizedDayJs(props.end).year(),
getLocalizedDayJs(props.end).month() + 1,
getLocalizedDayJs(props.end).date()
)
: undefined,
}),
set: (newValue) => {
if (newValue.start) {
const date = newValue.start.toDate(getLocalTimeZone());
emit('update:start', getDayJsInstance()(date).format('YYYY-MM-DD'));
}
if (newValue.end) {
const date = newValue.end.toDate(getLocalTimeZone());
emit('update:end', getDayJsInstance()(date).format('YYYY-MM-DD'));
}
},
});
const open = ref(false);
function setToday() {
start.value = getLocalizedDayJs().startOf('day').format();
end.value = getLocalizedDayJs().endOf('day').format();
emit('submit');
emit(
'update:start',
getLocalizedDayJs().startOf('day').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().endOf('day').format('YYYY-MM-DD'));
open.value = false;
}
function setThisWeek() {
start.value = getLocalizedDayJs().startOf('week').format();
end.value = getLocalizedDayJs().endOf('week').format();
emit('submit');
emit(
'update:start',
getLocalizedDayJs().startOf('week').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().endOf('week').format('YYYY-MM-DD'));
open.value = false;
}
function setLastWeek() {
start.value = getLocalizedDayJs()
.subtract(1, 'week')
.startOf('week')
.format();
end.value = getLocalizedDayJs().subtract(1, 'week').endOf('week').format();
emit('submit');
emit(
'update:start',
getLocalizedDayJs()
.subtract(1, 'week')
.startOf('week')
.format('YYYY-MM-DD')
);
emit(
'update:end',
getLocalizedDayJs()
.subtract(1, 'week')
.endOf('week')
.format('YYYY-MM-DD')
);
open.value = false;
}
function setLast14Days() {
start.value = getLocalizedDayJs().subtract(14, 'days').format();
end.value = getLocalizedDayJs().format();
emit('submit');
emit(
'update:start',
getLocalizedDayJs().subtract(14, 'days').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().format('YYYY-MM-DD'));
open.value = false;
}
function setThisMonth() {
start.value = getLocalizedDayJs().startOf('month').format();
end.value = getLocalizedDayJs().endOf('month').format();
emit('submit');
emit(
'update:start',
getLocalizedDayJs().startOf('month').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().endOf('month').format('YYYY-MM-DD'));
open.value = false;
}
function setLastMonth() {
start.value = getLocalizedDayJs()
.subtract(1, 'month')
.startOf('month')
.format();
end.value = getLocalizedDayJs()
.subtract(1, 'month')
.endOf('month')
.format();
emit('submit');
emit(
'update:start',
getLocalizedDayJs()
.subtract(1, 'month')
.startOf('month')
.format('YYYY-MM-DD')
);
emit(
'update:end',
getLocalizedDayJs()
.subtract(1, 'month')
.endOf('month')
.format('YYYY-MM-DD')
);
open.value = false;
}
function setLast30Days() {
start.value = getLocalizedDayJs().subtract(30, 'days').format();
end.value = getLocalizedDayJs().format();
emit('submit');
emit(
'update:start',
getLocalizedDayJs().subtract(30, 'days').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().format('YYYY-MM-DD'));
open.value = false;
}
function setLast90Days() {
start.value = getDayJsInstance()().subtract(90, 'days').format();
end.value = getDayJsInstance()().format();
emit('submit');
emit(
'update:start',
getDayJsInstance()().subtract(90, 'days').format('YYYY-MM-DD')
);
emit('update:end', getDayJsInstance()().format('YYYY-MM-DD'));
open.value = false;
}
function setLast12Months() {
start.value = getLocalizedDayJs().subtract(12, 'months').format();
end.value = getLocalizedDayJs().format();
emit('submit');
emit(
'update:start',
getLocalizedDayJs().subtract(12, 'months').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().format('YYYY-MM-DD'));
open.value = false;
}
function setThisYear() {
start.value = getLocalizedDayJs().startOf('year').format();
end.value = getLocalizedDayJs().endOf('year').format();
emit('submit');
emit(
'update:start',
getLocalizedDayJs().startOf('year').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().endOf('year').format('YYYY-MM-DD'));
open.value = false;
}
function setLastYear() {
start.value = getLocalizedDayJs()
.subtract(1, 'year')
.startOf('year')
.format();
end.value = getLocalizedDayJs().subtract(1, 'year').endOf('year').format();
emit('submit');
emit(
'update:start',
getLocalizedDayJs()
.subtract(1, 'year')
.startOf('year')
.format('YYYY-MM-DD')
);
emit(
'update:end',
getLocalizedDayJs()
.subtract(1, 'year')
.endOf('year')
.format('YYYY-MM-DD')
);
open.value = false;
}
const organization = inject<ComputedRef<Organization>>('organization');
watch(open, (value) => {
if (value === false) {
emit('submit');
}
});
</script>
<template>
<Dropdown
v-model="open"
:close-on-content-click="false"
align="end"
@submit="emit('submit')">
<template #trigger>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<button
class="px-2 py-1 bg-input-background border border-input-border font-medium rounded-lg flex items-center space-x-2">
<CalendarIcon class="w-5"></CalendarIcon>
<div class="text-text-primary">
{{ formatDateLocalized(start) }}
<span class="px-1.5 text-text-secondary">-</span>
{{ formatDateLocalized(end) }}
</div>
:class="
twMerge(
'flex w-full items-center justify-between whitespace-nowrap rounded-md border border-input-border bg-input-background px-3 h-[34px] shadow-sm data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
!modelValue && 'text-muted-foreground'
)
">
<CalendarIcon class="mr-2 h-4 w-4" />
<template v-if="modelValue.start">
<template v-if="modelValue.end">
{{ formatDateLocalized(modelValue.start.toString(), organization?.date_format) }}
-
{{ formatDateLocalized(modelValue.end.toString(), organization?.date_format) }}
</template>
<template v-else>
{{ formatDateLocalized(modelValue.start.toString(), organization?.date_format) }}
</template>
</template>
<template v-else> Pick a date </template>
</button>
</template>
<template #content>
<div class="overflow-hidden w-[330px] px-3 py-1.5">
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<div class="flex divide-x divide-border-secondary">
<div
class="flex divide-x divide-border-secondary justify-between">
<div
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 [&_button:hover]:bg-tertiary [&_button]:rounded [&_button]:px-2 [&_button]:py-1">
<button @click="setToday">Today</button>
<button @click="setThisWeek">This Week</button>
<button @click="setLastWeek">Last Week</button>
<button @click="setLast14Days">Last 14 days</button>
<button @click="setThisMonth">This Month</button>
<button @click="setLastMonth">Last Month</button>
<button @click="setLast30Days">Last 30 days</button>
<button @click="setLast90Days">Last 90 days</button>
<button @click="setLast12Months">Last 12 months</button>
<button @click="setThisYear">This year</button>
<button @click="setLastYear">Last year</button>
</div>
<div class="pl-5">
<div class="space-y-1 flex-col flex items-start">
<div class="text-xs font-semibold text-text-secondary">
Start Date
</div>
<DatePicker v-model="start"></DatePicker>
</div>
<div class="mt-2 space-y-1 flex-col flex items-start">
<div class="text-sm font-medium text-text-secondary">
End Date
</div>
<DatePicker v-model="end"></DatePicker>
</div>
</div>
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2 [&_button:hover]:bg-tertiary [&_button]:rounded [&_button]:px-2 [&_button]:py-1">
<button @click="setToday">Today</button>
<button @click="setThisWeek">This Week</button>
<button @click="setLastWeek">Last Week</button>
<button @click="setLast14Days">Last 14 days</button>
<button @click="setThisMonth">This Month</button>
<button @click="setLastMonth">Last Month</button>
<button @click="setLast30Days">Last 30 days</button>
<button @click="setLast90Days">Last 90 days</button>
<button @click="setLast12Months">Last 12 months</button>
<button @click="setThisYear">This year</button>
<button @click="setLastYear">Last year</button>
</div>
<div class="pl-2">
<RangeCalendar
v-model="modelValue"
initial-focus
:number-of-months="2"
:max-value="today" />
</div>
</div>
</template>
</Dropdown>
</PopoverContent>
</Popover>
</template>
<style scoped></style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import parse from 'parse-duration';
import { onMounted, ref, watch } from 'vue';
import { onMounted, ref, watch, inject } from 'vue';
import {
formatHumanReadableDuration,
getDayJsInstance,
@@ -8,6 +8,9 @@ import {
import dayjs from 'dayjs';
import { twMerge } from 'tailwind-merge';
import { TextInput } from '@/packages/ui/src';
import type { Organization } from '@/packages/api/src';
import { type ComputedRef } from 'vue';
const temporaryCustomTimerEntry = ref<string>('');
const start = defineModel('start', {
@@ -18,6 +21,8 @@ const end = defineModel('end', {
default: '',
});
const organization = inject<ComputedRef<Organization>>('organization');
function isHHMM(value: string): boolean {
return HHMMtimeRegex.test(value);
}
@@ -70,7 +75,11 @@ function updateTimeEntryInputValue() {
if (start.value && end.value) {
const startTime = dayjs(start.value);
const diff = getDayJsInstance()(end.value).diff(startTime, 'seconds');
temporaryCustomTimerEntry.value = formatHumanReadableDuration(diff);
temporaryCustomTimerEntry.value = formatHumanReadableDuration(
diff,
organization?.value?.interval_format,
organization?.value?.number_format
);
}
}
</script>

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import { formatCents } from '@/packages/ui/src/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{
newBillableRate?: number | null;
projectName: string;
@@ -26,7 +30,13 @@ defineEmits<{
The billable rate of {{ projectName }} will be updated to
<strong>{{
newBillableRate
? formatCents(newBillableRate, currency)
? formatCents(
newBillableRate,
currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: ' the default rate of the organization member'
}}</strong
>.

View File

@@ -9,13 +9,14 @@ import type {
Task,
TimeEntry,
Client,
Organization,
} from '@/packages/api/src';
import TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDescriptionInput.vue';
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import { ref } from 'vue';
import { ref, inject, type ComputedRef } from 'vue';
import {
formatHumanReadableDuration,
formatStartEnd,
@@ -24,7 +25,7 @@ import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
import { Checkbox } from '@/packages/ui/src';
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
timeEntry: TimeEntriesGroupedByType;
projects: Project[];
@@ -48,6 +49,8 @@ const emit = defineEmits<{
unselected: [TimeEntry[]];
}>();
const organization = inject<ComputedRef<Organization>>('organization');
function updateTimeEntryDescription(description: string) {
props.updateTimeEntries(
props.timeEntry.timeEntries.map((timeEntry: TimeEntry) => timeEntry.id),
@@ -113,10 +116,10 @@ function onSelectChange(checked: boolean) {
</GroupedItemsCountButton>
<TimeEntryDescriptionInput
class="min-w-0 mr-4"
:model-value="
timeEntry.description
"
@changed="updateTimeEntryDescription"></TimeEntryDescriptionInput>
:model-value="timeEntry.description"
@changed="
updateTimeEntryDescription
"></TimeEntryDescriptionInput>
<TimeTrackerProjectTaskDropdown
:clients
:create-project
@@ -128,10 +131,10 @@ function onSelectChange(checked: boolean) {
:project="timeEntry.project_id"
:enable-estimated-time
:currency="currency"
:task="
timeEntry.task_id
"
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
:task="timeEntry.task_id"
@changed="
updateProjectAndTask
"></TimeTrackerProjectTaskDropdown>
</div>
</div>
<div class="flex items-center font-medium lg:space-x-2">
@@ -139,7 +142,9 @@ function onSelectChange(checked: boolean) {
:create-tag
:tags="tags"
:model-value="timeEntry.tags"
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
@changed="
updateTimeEntryTags
"></TimeEntryRowTagDropdown>
<BillableToggleButton
:model-value="timeEntry.billable"
class="opacity-50 focus-visible:opacity-100 group-hover:opacity-100"
@@ -149,23 +154,29 @@ function onSelectChange(checked: boolean) {
"></BillableToggleButton>
<div class="flex-1">
<button
class="hidden lg:block text-text-secondary w-[110px] px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
:class="twMerge('hidden lg:block text-text-secondary w-[110px] px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary', organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[110px]')"
@click="expanded = !expanded">
{{ formatStartEnd(timeEntry.start, timeEntry.end) }}
{{ formatStartEnd(timeEntry.start, timeEntry.end, organization?.time_format) }}
</button>
</div>
<button
class="text-text-primary min-w-[90px] px-2 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
class="text-text-primary min-w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
@click="expanded = !expanded">
{{
formatHumanReadableDuration(timeEntry.duration ?? 0)
formatHumanReadableDuration(
timeEntry.duration ?? 0,
organization?.interval_format,
organization?.number_format
)
}}
</button>
<TimeTrackerStartStop
:active="!!(timeEntry.start && !timeEntry.end)"
class="opacity-20 hidden sm:flex group-hover:opacity-100 focus-visible:opacity-100"
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
@changed="
onStartStopClick(timeEntry)
"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
@delete="
deleteTimeEntries(timeEntry?.timeEntries ?? [])

View File

@@ -230,7 +230,8 @@ type BillableOption = {
<div class="space-y-2 mt-1 flex flex-col">
<DurationHumanInput
v-model:start="localStart"
v-model:end="localEnd"></DurationHumanInput>
v-model:end="localEnd"
name="Duration"></DurationHumanInput>
<div class="text-sm flex space-x-1">
<InformationCircleIcon
class="w-4 text-text-quaternary"></InformationCircleIcon>

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { defineProps, ref } from 'vue';
import { defineProps, ref, inject, type ComputedRef } from 'vue';
import {
formatDateLocalized,
formatStartEnd,
} from '@/packages/ui/src/utils/time';
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
import { twMerge } from 'tailwind-merge';
import type { Organization } from '@/packages/api/src';
defineProps<{
start: string;
@@ -20,6 +21,9 @@ const emit = defineEmits<{
const open = ref(false);
const triggerElement = ref<HTMLButtonElement | null>(null);
const organization = inject<ComputedRef<Organization>>('organization');
</script>
<template>
@@ -35,16 +39,17 @@ const triggerElement = ref<HTMLButtonElement | null>(null);
data-testid="time_entry_range_selector"
:class="
twMerge(
'text-text-secondary w-[110px] px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
'text-text-secondary px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
showDate
? 'text-xs py-1.5 font-semibold'
: 'text-sm py-1.5 font-medium',
organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[110px]',
open && 'border-card-border bg-card-background'
)
">
{{ formatStartEnd(start, end) }}
{{ formatStartEnd(start, end, organization?.time_format) }}
<span v-if="showDate" class="text-text-tertiary font-medium"
>{{ formatDateLocalized(start) }}
>{{ formatDateLocalized(start, organization?.date_format) }}
</span>
</button>
</template>

View File

@@ -2,10 +2,18 @@
import {
calculateDifference,
formatHumanReadableDuration,
parseTimeInput,
} from '@/packages/ui/src/utils/time';
import { computed, defineProps, ref } from 'vue';
import parse from 'parse-duration';
import { computed, defineProps, ref, inject, type ComputedRef } from 'vue';
import dayjs from 'dayjs';
import type { Organization } from '@/packages/api/src';
const organization = inject<ComputedRef<Organization>>('organization');
const organizationSettings = computed(() => ({
intervalFormat: organization?.value?.interval_format ?? 'hours-minutes',
numberFormat: organization?.value?.number_format ?? 'point',
}));
const props = defineProps<{
start: string;
@@ -19,15 +27,22 @@ const temporaryCustomTimerEntry = ref<string>('');
const open = ref(false);
function updateTimerAndStartLiveTimerUpdate() {
const time = parse(temporaryCustomTimerEntry.value, 's');
if (time && time > 0) {
const defaultUnit =
organizationSettings?.value?.intervalFormat === 'decimal'
? 'hours'
: 'minutes';
const { seconds } = parseTimeInput(
temporaryCustomTimerEntry.value,
defaultUnit
);
if (seconds && seconds > 0) {
let newEndDate = props.end;
let newStartDate = props.start;
if (props.end) {
// only update end for time entries that are already finished
newEndDate = dayjs(props.start).utc().add(time, 's').format();
newEndDate = dayjs(props.start).utc().add(seconds, 's').format();
} else {
newStartDate = dayjs().utc().subtract(time, 's').format();
newStartDate = dayjs().utc().subtract(seconds, 's').format();
}
emit('changed', newStartDate, newEndDate);
}
@@ -40,7 +55,9 @@ const currentTime = computed({
return temporaryCustomTimerEntry.value;
}
return formatHumanReadableDuration(
calculateDifference(props.start, props.end)
calculateDifference(props.start, props.end),
organizationSettings.value.intervalFormat,
organizationSettings.value.numberFormat
);
},
// setter
@@ -64,7 +81,8 @@ function selectInput(event: Event) {
<input
v-model="currentTime"
data-testid="time_entry_duration_input"
class="text-text-primary w-[90px] px-2 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
name="Duration"
class="text-text-primary w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
@focus="selectInput"
@keydown.tab="open = false"
@blur="updateTimerAndStartLiveTimerUpdate"

View File

@@ -6,6 +6,11 @@ import {
formatWeekday,
} from '@/packages/ui/src/utils/time';
import Checkbox from '../Input/Checkbox.vue';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{
date: string;
duration: number;
@@ -53,12 +58,18 @@ function selectUnselectAll(value: boolean) {
{{ formatWeekday(date) }}
</span>
<span class="font-semibold text-text-secondary">
{{ formatDate(date) }}
{{ formatDate(date, organization?.date_format) }}
</span>
</div>
<div class="text-text-secondary pr-[90px] lg:pr-[92px]">
<span class="font-semibold">
{{ formatHumanReadableDuration(duration) }}
{{
formatHumanReadableDuration(
duration,
organization?.interval_format,
organization?.number_format
)
}}
</span>
</div>
</div>

View File

@@ -3,8 +3,11 @@ import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { computed, ref } from 'vue';
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
import dayjs, { Dayjs } from 'dayjs';
import parse from 'parse-duration';
import { formatDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
import {
formatDuration,
getDayJsInstance,
parseTimeInput,
} from '@/packages/ui/src/utils/time';
import type { TimeEntry } from '@/packages/api/src';
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
@@ -28,6 +31,7 @@ function pauseLiveTimerUpdate(event: FocusEvent) {
function onTimeEntryEnterPress() {
updateTimerAndStartLiveTimerUpdate();
open.value = false;
const activeElement = document.activeElement as HTMLElement;
activeElement?.blur();
}
@@ -55,36 +59,13 @@ const currentTime = computed({
});
function updateTimerAndStartLiveTimerUpdate() {
const time = parse(temporaryCustomTimerEntry.value, 's');
const { seconds } = parseTimeInput(
temporaryCustomTimerEntry.value,
'minutes'
);
if (isNumeric(temporaryCustomTimerEntry.value)) {
const newStartDate = dayjs().subtract(
parseInt(temporaryCustomTimerEntry.value),
'm'
);
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
emit('updateTimer');
} else {
emit('startTimer');
}
} else if (isHHMM(temporaryCustomTimerEntry.value)) {
const results = parseHHMM(temporaryCustomTimerEntry.value);
if (results) {
const newStartDate = dayjs()
.subtract(parseInt(results[1]), 'h')
.subtract(parseInt(results[2]), 'm');
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
emit('updateTimer');
} else {
emit('startTimer');
}
}
}
// try to parse natural language like "1h 30m"
else if (time && time > 1) {
const newStartDate = dayjs().subtract(time, 's');
if (seconds && seconds > 0) {
const newStartDate = dayjs().subtract(seconds, 's');
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
emit('updateTimer');
@@ -92,26 +73,11 @@ function updateTimerAndStartLiveTimerUpdate() {
emit('startTimer');
}
}
// fallback to minutes if just a number is given
now.value = dayjs().utc();
temporaryCustomTimerEntry.value = '';
emit('startLiveTimer');
}
function isNumeric(value: string) {
return /^-?\d+$/.test(value);
}
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
function isHHMM(value: string): boolean {
return HHMMtimeRegex.test(value);
}
function parseHHMM(value: string): string[] | null {
return value.match(HHMMtimeRegex);
}
const temporaryCustomTimerEntry = ref<string>('');
async function updateTimeRange(newStart: string) {
@@ -161,8 +127,8 @@ function focusNextElement(e: KeyboardEvent) {
}
function closeAndFocusInput() {
inputField.value?.focus();
open.value = false;
inputField.value?.focus();
}
</script>
@@ -173,7 +139,7 @@ function closeAndFocusInput() {
align="center"
:auto-focus="false"
:close-on-content-click="false"
@submit="open = false">
@submit="closeAndFocusInput">
<template #trigger>
<input
ref="inputField"

View File

@@ -1,12 +1,52 @@
function formatMoney(amount: number, currency: string) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: currency,
}).format(amount);
import { formatNumber, type NumberFormat } from './number';
export type CurrencyFormat =
| 'iso-code-before-with-space'
| 'iso-code-after-with-space'
| 'symbol-before'
| 'symbol-after'
| 'symbol-before-with-space'
| 'symbol-after-with-space';
function formatMoney(
amount: number,
currency?: string,
format?: CurrencyFormat,
currencySymbol?: string,
numberFormat?: NumberFormat
) {
const formattedAmount = formatNumber(amount, numberFormat);
switch (format) {
case 'iso-code-before-with-space':
return `${currency} ${formattedAmount}`;
case 'iso-code-after-with-space':
return `${formattedAmount} ${currency}`;
case 'symbol-before':
return `${currencySymbol}${formattedAmount}`;
case 'symbol-after':
return `${formattedAmount}${currencySymbol}`;
case 'symbol-before-with-space':
return `${currencySymbol} ${formattedAmount}`;
case 'symbol-after-with-space':
return `${formattedAmount} ${currencySymbol}`;
}
}
export function formatCents(amount: number, currency: string) {
return formatMoney(amount / 100, currency);
export function formatCents(
amount: number,
currency?: string,
format?: CurrencyFormat,
currencySymbol?: string,
numberFormat?: NumberFormat
) {
return formatMoney(
amount / 100,
currency,
format,
currencySymbol,
numberFormat
);
}
export function getOrganizationCurrencySymbol(currency: string) {

View File

@@ -0,0 +1,41 @@
export type NumberFormat =
| 'point-comma'
| 'comma-point'
| 'space-comma'
| 'space-point'
| 'apostrophe-point';
/**
* Formats a number according to the specified format
* @param value - The number to format
* @param format - The format to use
* @returns The formatted number as a string
*/
export function formatNumber(value: number, format?: string): string {
// Convert to fixed 2 decimal places first
const parts = value.toFixed(2).split('.');
const wholePart = parts[0];
const decimalPart = parts[1];
// Format the whole number part based on the format
let formattedWhole: string;
switch (format) {
case 'point-comma':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `${formattedWhole},${decimalPart}`;
case 'comma-point':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `${formattedWhole}.${decimalPart}`;
case 'space-comma':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return `${formattedWhole},${decimalPart}`;
case 'space-point':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return `${formattedWhole}.${decimalPart}`;
case 'apostrophe-point':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, "'");
return `${formattedWhole}.${decimalPart}`;
default:
return value.toString();
}
}

View File

@@ -6,10 +6,38 @@ import isYesterday from 'dayjs/plugin/isYesterday';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import parse from 'parse-duration';
import { getUserTimezone, getWeekStart } from './settings';
import updateLocale from 'dayjs/plugin/updateLocale';
import { computed } from 'vue';
import { formatNumber } from './number';
export type DateFormat =
| 'point-separated-d-m-yyyy'
| 'slash-separated-mm-dd-yyyy'
| 'slash-separated-dd-mm-yyyy'
| 'hyphen-separated-dd-mm-yyyy'
| 'hyphen-separated-mm-dd-yyyy'
| 'hyphen-separated-yyyy-mm-dd';
const dateFormatMap: Record<DateFormat, string> = {
'point-separated-d-m-yyyy': 'D.M.YYYY',
'slash-separated-mm-dd-yyyy': 'MM/DD/YYYY',
'slash-separated-dd-mm-yyyy': 'DD/MM/YYYY',
'hyphen-separated-dd-mm-yyyy': 'DD-MM-YYYY',
'hyphen-separated-mm-dd-yyyy': 'MM-DD-YYYY',
'hyphen-separated-yyyy-mm-dd': 'YYYY-MM-DD'
};
export type TimeFormat = '12-hours' | '24-hours';
export type IntervalFormat =
| 'decimal'
| 'hours-minutes'
| 'hours-minutes-colon-separated'
| 'hours-minutes-seconds-colon-separated';
export type TimeInputUnit = 'minutes' | 'hours';
dayjs.extend(relativeTime);
dayjs.extend(isToday);
@@ -40,11 +68,28 @@ export const firstDayIndex = computed(() => {
return apiDayOrder.indexOf(getWeekStart());
});
export function formatHumanReadableDuration(duration: number): string {
export function formatHumanReadableDuration(
duration: number,
intervalFormat?: string,
numberFormat?: string
): string {
const dayJsDuration = dayjs.duration(duration, 's');
const hours = Math.floor(dayJsDuration.asHours());
const minutes = dayJsDuration.minutes();
return `${hours}h ${minutes.toString().padStart(2, '0')}min`;
const seconds = dayJsDuration.seconds();
switch (intervalFormat) {
case 'decimal':
return formatNumber(dayJsDuration.asHours(), numberFormat) + ' h';
case 'hours-minutes':
return `${hours}h ${minutes.toString().padStart(2, '0')}min`;
case 'hours-minutes-colon-separated':
return `${hours}:${minutes.toString().padStart(2, '0')}`;
case 'hours-minutes-seconds-colon-separated':
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
default:
return `${hours}h ${minutes.toString().padStart(2, '0')}min`;
}
}
export function formatDuration(duration: number): string {
@@ -65,9 +110,11 @@ export function calculateDifference(start: string, end: string | null) {
/**
* Returns a formatted time.
* @param date - A UTC date time string.
* @param timeFormat - The time format to use ('12-hours' or '24-hours')
*/
export function formatTime(date: string) {
return dayjs.utc(date).tz(getUserTimezone()).format('HH:mm');
export function formatTime(date: string, timeFormat: TimeFormat = '24-hours') {
const format = timeFormat === '12-hours' ? 'hh:mm A' : 'HH:mm';
return dayjs.utc(date).tz(getUserTimezone()).format(format);
}
export function getLocalizedDayJs(timestamp?: string | null) {
@@ -82,21 +129,21 @@ export function getLocalizedDateFromTimestamp(timestamp: string) {
* Returns a formatted date.
* @param date - date in the format of 'YYYY-MM-DD'
*/
export function formatDate(date: string): string {
export function formatDate(date: string, format: DateFormat = 'point-separated-d-m-yyyy'): string {
if (date?.includes('+')) {
console.warn(
'Date contains timezone information, use formatDateLocalized instead'
);
}
return getDayJsInstance()(date).format('DD.MM.YYYY');
return getDayJsInstance()(date).format(dateFormatMap[format]);
}
/*
* Returns a formatted date.
* @param date - date in the format of 'YYYY-MM-DD'
*/
export function formatDateLocalized(date: string): string {
return getLocalizedDayJs(date).format('DD.MM.YYYY');
export function formatDateLocalized(date: string, format: DateFormat = 'point-separated-d-m-yyyy'): string {
return getLocalizedDayJs(date).format(dateFormatMap[format]);
}
export function formatDateTimeLocalized(date: string): string {
@@ -124,10 +171,51 @@ export function formatWeekday(date: string) {
return dayjs(date).format('dddd');
}
export function formatStartEnd(start: string, end: string | null) {
export function formatStartEnd(start: string, end: string | null, timeFormat: TimeFormat = '24-hours') {
if (end) {
return `${formatTime(start)} - ${formatTime(end)}`;
return `${formatTime(start, timeFormat)} - ${formatTime(end, timeFormat)}`;
} else {
return `${formatTime(start)} - ...`;
return `${formatTime(start, timeFormat)} - ...`;
}
}
export function parseTimeInput(
input: string,
defaultUnit: TimeInputUnit = 'minutes'
): {
seconds: number | null;
isHHMM: boolean;
} {
// Check if input is a decimal number (hours)
const decimalRegex = /^-?\d+[.,]\d+$/;
if (decimalRegex.test(input)) {
const hours = parseFloat(input.replace(',', '.'));
return { seconds: Math.round(hours * 3600), isHHMM: false };
}
// Check if input is just a number (minutes or hours based on defaultUnit)
if (/^-?\d+$/.test(input)) {
const value = parseInt(input);
const seconds = defaultUnit === 'minutes' ? value * 60 : value * 3600;
return { seconds, isHHMM: false };
}
// Check if input is in HH:MM format
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
if (HHMMtimeRegex.test(input)) {
const match = input.match(HHMMtimeRegex);
if (match) {
const hours = parseInt(match[1]);
const minutes = parseInt(match[2]);
return { seconds: (hours * 60 + minutes) * 60, isHHMM: true };
}
}
// Try to parse natural language like "1h 30m"
const parsedDuration = parse(input, 's');
if (parsedDuration && parsedDuration > 0) {
return { seconds: parsedDuration, isHHMM: false };
}
return { seconds: null, isHHMM: false };
}

View File

@@ -14,6 +14,7 @@ import {
import dayjs from 'dayjs';
import { useNotificationsStore } from '@/utils/notification';
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
import { useQueryClient } from "@tanstack/vue-query";
export const useTimeEntriesStore = defineStore('timeEntries', () => {
const timeEntries = ref<TimeEntry[]>(reactive([]));
@@ -21,6 +22,8 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
const allTimeEntriesLoaded = ref(false);
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
async function patchTimeEntries(
queryParams: TimeEntriesQueryParams = {
only_full_dates: 'true',
@@ -157,6 +160,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
timeEntries.value = timeEntries.value.map((entry) =>
entry.id === timeEntry.id ? response.data : entry
);
queryClient.invalidateQueries({queryKey: ['timeEntry']});
}
}

View File

@@ -185,7 +185,7 @@
{{ $localization->formatDate($timeEntry->start->timezone($timezone)) }} - <br> {{ $localization->formatDate($timeEntry->end->timezone($timezone)) }}
@endif
<br>
{{ $localization->formatDate($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatDate($timeEntry->end->timezone($timezone)) }}
{{ $localization->formatTime($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatTime($timeEntry->end->timezone($timezone)) }}
</td>
<td style="overflow-wrap: break-word; min-width: 75px;">
{{ $localization->formatInterval($timeEntry->getDuration()) }}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
use App\Http\Controllers\Api\V1\ApiTokenController;
use App\Http\Controllers\Api\V1\ChartController;
use App\Http\Controllers\Api\V1\ClientController;
use App\Http\Controllers\Api\V1\CurrencyController;
use App\Http\Controllers\Api\V1\ExportController;
use App\Http\Controllers\Api\V1\ImportController;
use App\Http\Controllers\Api\V1\InvitationController;
@@ -173,6 +174,8 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
});
});
Route::get('/currencies', [CurrencyController::class, 'index'])->name('currencies.index');
// Public routes
Route::name('public.')->prefix('/public')->group(static function (): void {
Route::get('/reports', [PublicReportController::class, 'show'])->name('reports.show');

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Http\Controllers\Api\V1\CurrencyController;
use App\Service\CurrencyService;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
#[CoversClass(CurrencyController::class)]
#[CoversClass(CurrencyService::class)]
#[UsesClass(CurrencyController::class)]
class CurrencyEndpointTest extends ApiEndpointTestAbstract
{
public function test_index_return_list_of_available_currencies_incl_symbol(): void
{
// Arrange
// Act
$response = $this->getJson(route('api.v1.currencies.index'));
// Assert
$response->assertOk();
$response->assertJsonCount(166);
$responseObj = collect($response->json());
$this->assertSame([
'code' => 'EUR',
'name' => 'Euro',
'symbol' => '€',
], $responseObj->firstWhere('code', '=', 'EUR'));
}
}

View File

@@ -14,6 +14,7 @@ use App\Models\Report;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\CurrencyService;
use App\Service\Dto\ReportPropertiesDto;
use Illuminate\Support\Str;
use Tests\Unit\Endpoint\Api\V1\ApiEndpointTestAbstract;
@@ -104,6 +105,8 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
TimeEntry::factory()->forOrganization($organization)->forTask($task2)->startWithDuration(now()->subDay(), 100)->create();
TimeEntry::factory()->forOrganization($organization)->startWithDuration(now()->subDay(), 100)->create();
$currencyService = app(CurrencyService::class);
// Act
$response = $this->getJson(route('api.v1.public.reports.show'), [
'X-Api-Key' => $report->share_secret,
@@ -116,6 +119,12 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
'description' => $report->description,
'public_until' => $report->public_until?->toIso8601ZuluString(),
'currency' => $organization->currency,
'number_format' => $organization->number_format,
'interval_format' => $organization->interval_format,
'currency_format' => $organization->currency_format,
'currency_symbol' => $currencyService->getCurrencySymbol($organization->currency),
'time_format' => $organization->time_format,
'date_format' => $organization->date_format,
'properties' => [
'group' => $reportDto->group->value,
'sub_group' => $reportDto->subGroup->value,

View File

@@ -29,7 +29,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
parent::setUp();
$this->localizationService = new LocalizationService(
CurrencyFormat::SymbolAfterWithSpace,
DateFormat::PointSeperatedDMYYYY,
DateFormat::PointSeparatedDMYYYY,
TimeFormat::TwelveHours,
NumberFormat::ThousandsPointDecimalComma,
IntervalFormat::Decimal,
@@ -105,11 +105,11 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$this->assertSame('30001h 03m', $formatted);
}
public function test_format_interval_with_type_hours_minutes_colon_seperated(): void
public function test_format_interval_with_type_hours_minutes_colon_separated(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesColonSeperated);
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesColonSeparated);
// Act
$formatted = $this->localizationService->formatInterval($interval);
@@ -118,11 +118,11 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$this->assertSame('30001:03', $formatted);
}
public function test_format_interval_with_type_hours_minutes_seconds_colon_seperated(): void
public function test_format_interval_with_type_hours_minutes_seconds_colon_separated(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesSecondsColonSeperated);
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesSecondsColonSeparated);
// Act
$formatted = $this->localizationService->formatInterval($interval);
@@ -215,10 +215,10 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$this->assertSame('EUR 1 234 567,89', $formatted);
}
public function test_format_date_with_type_slash_seperated_ddmmy(): void
public function test_format_date_with_type_slash_separated_ddmmy(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeperatedDDMMYYYY);
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
$date = Carbon::createFromDate(2001, 2, 3);
// Act

View File

@@ -18,5 +18,8 @@
"resources/js/**/*.d.ts",
"resources/js/**/*.vue",
"resources/js/ziggy.d.ts",
"extensions/Invoicing/resources/js/**/*.ts",
"extensions/Invoicing/resources/js/**/*.d.ts",
"extensions/Invoicing/resources/js/**/*.vue",
]
}