mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
10 Commits
feature/ro
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3d7cdaf4d | ||
|
|
b11672732b | ||
|
|
97dcadc795 | ||
|
|
e7fa414c06 | ||
|
|
43073b5be2 | ||
|
|
9589c9106d | ||
|
|
8a0d2235a8 | ||
|
|
38f38790d5 | ||
|
|
e3cfc155b8 | ||
|
|
4b726635b2 |
23
.github/workflows/npm-format-check.yml
vendored
Normal file
23
.github/workflows/npm-format-check.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: NPM Format Check
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
format-check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: "Install npm dependencies"
|
||||
run: npm ci
|
||||
|
||||
- name: "Check code formatting"
|
||||
run: npm run format:check
|
||||
27
.prettierignore
Normal file
27
.prettierignore
Normal file
@@ -0,0 +1,27 @@
|
||||
# Ignore build outputs
|
||||
node_modules/
|
||||
vendor/
|
||||
storage/
|
||||
bootstrap/cache/
|
||||
public/build/
|
||||
public/hot/
|
||||
|
||||
# Ignore lock files
|
||||
package-lock.json
|
||||
composer.lock
|
||||
|
||||
# Ignore generated files
|
||||
*.min.js
|
||||
*.min.css
|
||||
|
||||
# Ignore test results
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Ignore IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Ignore OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -3,5 +3,6 @@
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true,
|
||||
"bracketSameLine": true,
|
||||
"quoteProps": "preserve"
|
||||
"quoteProps": "preserve",
|
||||
"printWidth": 100
|
||||
}
|
||||
|
||||
16
app/Enums/TimeEntryRoundingType.php
Normal file
16
app/Enums/TimeEntryRoundingType.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum TimeEntryRoundingType: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Up = 'up';
|
||||
case Down = 'down';
|
||||
case Nearest = 'nearest';
|
||||
}
|
||||
@@ -73,7 +73,9 @@ class ReportController extends Controller
|
||||
false,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
true
|
||||
true,
|
||||
$report->properties->roundingType,
|
||||
$report->properties->roundingMinutes,
|
||||
);
|
||||
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||
$timeEntriesQuery->clone(),
|
||||
@@ -84,7 +86,9 @@ class ReportController extends Controller
|
||||
true,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
true
|
||||
true,
|
||||
$report->properties->roundingType,
|
||||
$report->properties->roundingMinutes,
|
||||
);
|
||||
|
||||
return new DetailedWithDataReportResource($report, $data, $historyData);
|
||||
|
||||
@@ -107,6 +107,8 @@ class ReportController extends Controller
|
||||
}
|
||||
}
|
||||
$properties->timezone = $timezone;
|
||||
$properties->roundingType = $request->getPropertyRoundingType();
|
||||
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
|
||||
$report->properties = $properties;
|
||||
if ($isPublic) {
|
||||
$report->share_secret = $reportService->generateSecret();
|
||||
|
||||
@@ -33,6 +33,7 @@ use App\Service\ReportExport\TimeEntriesDetailedExport;
|
||||
use App\Service\ReportExport\TimeEntriesReportExport;
|
||||
use App\Service\TimeEntryAggregationService;
|
||||
use App\Service\TimeEntryFilter;
|
||||
use App\Service\TimeEntryService;
|
||||
use App\Service\TimezoneService;
|
||||
use Gotenberg\Exceptions\GotenbergApiErrored;
|
||||
use Gotenberg\Exceptions\NoOutputFileInResponse;
|
||||
@@ -47,6 +48,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
@@ -84,7 +86,8 @@ class TimeEntryController extends Controller
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
}
|
||||
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||
|
||||
$totalCount = $timeEntriesQuery->count();
|
||||
|
||||
@@ -138,10 +141,19 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* @return Builder<TimeEntry>
|
||||
*/
|
||||
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
|
||||
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member, bool $canAccessPremiumFeatures): Builder
|
||||
{
|
||||
$select = TimeEntry::SELECT_COLUMNS;
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
if ($roundingType !== null && $roundingMinutes !== null) {
|
||||
$select = array_diff($select, ['start', 'end']);
|
||||
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');
|
||||
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');
|
||||
}
|
||||
$timeEntriesQuery = TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->select($select)
|
||||
->orderBy('start', 'desc');
|
||||
|
||||
$filter = new TimeEntryFilter($timeEntriesQuery);
|
||||
@@ -175,16 +187,19 @@ class TimeEntryController extends Controller
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
}
|
||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||
$debug = $request->getDebug();
|
||||
$format = $request->getFormatValue();
|
||||
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
||||
if ($format === ExportFormat::PDF && ! $canAccessPremiumFeatures) {
|
||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||
}
|
||||
$user = $this->user();
|
||||
$timezone = $user->timezone;
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||
$timeEntriesQuery->with([
|
||||
'task',
|
||||
'client',
|
||||
@@ -207,8 +222,9 @@ class TimeEntryController extends Controller
|
||||
if ($viewFile === false) {
|
||||
throw new \LogicException('View file not found');
|
||||
}
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesQuery->clone()->reorder()->withOnly([]),
|
||||
$timeEntriesAggregateQuery,
|
||||
null,
|
||||
null,
|
||||
$user->timezone,
|
||||
@@ -216,7 +232,9 @@ class TimeEntryController extends Controller
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes,
|
||||
);
|
||||
$html = Blade::render($viewFile, [
|
||||
'timeEntries' => $timeEntriesQuery->get(),
|
||||
@@ -318,12 +336,15 @@ class TimeEntryController extends Controller
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
}
|
||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||
$user = $this->user();
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
$group1Type = $request->getGroup();
|
||||
$group2Type = $request->getSubGroup();
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery,
|
||||
@@ -334,7 +355,9 @@ class TimeEntryController extends Controller
|
||||
$request->getFillGapsInTimeGroups(),
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -362,6 +385,7 @@ class TimeEntryController extends Controller
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
}
|
||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||
$format = $request->getFormatValue();
|
||||
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||
@@ -373,6 +397,8 @@ class TimeEntryController extends Controller
|
||||
$group = $request->getGroup();
|
||||
$subGroup = $request->getSubGroup();
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||
$timeEntriesAggregateQuery->clone(),
|
||||
@@ -383,7 +409,9 @@ class TimeEntryController extends Controller
|
||||
false,
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
);
|
||||
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery->clone(),
|
||||
@@ -394,7 +422,9 @@ class TimeEntryController extends Controller
|
||||
true,
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
);
|
||||
$currency = $organization->currency;
|
||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||
@@ -477,7 +507,7 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* @return Builder<TimeEntry>
|
||||
*/
|
||||
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
|
||||
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
|
||||
{
|
||||
$timeEntriesQuery = TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization');
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Report;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Organization;
|
||||
@@ -128,6 +129,18 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
'nullable',
|
||||
'timezone:all',
|
||||
],
|
||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||
'properties.rounding_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TimeEntryRoundingType::class),
|
||||
],
|
||||
// Defines the length of the interval that the time entry rounding rounds to.
|
||||
'properties.rounding_minutes' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'integer',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -205,4 +218,22 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
{
|
||||
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
||||
}
|
||||
|
||||
public function getPropertyRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TimeEntryRoundingType::from($this->input('properties.rounding_type'));
|
||||
}
|
||||
|
||||
public function getPropertyRoundingMinutes(): ?int
|
||||
{
|
||||
if (! $this->has('properties.rounding_minutes') || $this->input('properties.rounding_minutes') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $this->input('properties.rounding_minutes');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\TimeEntry;
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
@@ -164,6 +165,18 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||
'rounding_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TimeEntryRoundingType::class),
|
||||
],
|
||||
// Defines the length of the interval that the time entry rounding rounds to.
|
||||
'rounding_minutes' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'integer',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -211,4 +224,22 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
{
|
||||
return ExportFormat::from($this->validated('format'));
|
||||
}
|
||||
|
||||
public function getRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TimeEntryRoundingType::from($this->validated('rounding_type'));
|
||||
}
|
||||
|
||||
public function getRoundingMinutes(): ?int
|
||||
{
|
||||
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $this->validated('rounding_minutes');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
@@ -146,6 +147,18 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||
'rounding_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TimeEntryRoundingType::class),
|
||||
],
|
||||
// Defines the length of the interval that the time entry rounding rounds to.
|
||||
'rounding_minutes' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'integer',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -173,4 +186,22 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
{
|
||||
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
|
||||
}
|
||||
|
||||
public function getRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TimeEntryRoundingType::from($this->validated('rounding_type'));
|
||||
}
|
||||
|
||||
public function getRoundingMinutes(): ?int
|
||||
{
|
||||
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $this->validated('rounding_minutes');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
@@ -133,6 +134,18 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||
'rounding_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TimeEntryRoundingType::class),
|
||||
],
|
||||
// Defines the length of the interval that the time entry rounding rounds to.
|
||||
'rounding_minutes' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'integer',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -170,4 +183,22 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
{
|
||||
return ExportFormat::from($this->validated('format'));
|
||||
}
|
||||
|
||||
public function getRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TimeEntryRoundingType::from($this->validated('rounding_type'));
|
||||
}
|
||||
|
||||
public function getRoundingMinutes(): ?int
|
||||
{
|
||||
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $this->validated('rounding_minutes');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
@@ -11,8 +12,10 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use Illuminate\Contracts\Validation\Rule as RuleContract;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
@@ -23,7 +26,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
* @return array<string, array<string|ValidationRule|RuleContract>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -136,6 +139,18 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||
'rounding_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TimeEntryRoundingType::class),
|
||||
],
|
||||
// Defines the length of the interval that the time entry rounding rounds to.
|
||||
'rounding_minutes' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'integer',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -153,4 +168,22 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
{
|
||||
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
||||
}
|
||||
|
||||
public function getRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TimeEntryRoundingType::from($this->validated('rounding_type'));
|
||||
}
|
||||
|
||||
public function getRoundingMinutes(): ?int
|
||||
{
|
||||
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $this->validated('rounding_minutes');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ class DetailedReportResource extends BaseResource
|
||||
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
||||
/** @var string|null $rounding_type Rounding type for time entries */
|
||||
'rounding_type' => $this->resource->properties->roundingType?->value,
|
||||
/** @var int|null $rounding_minutes Rounding minutes for time entries */
|
||||
'rounding_minutes' => $this->resource->properties->roundingMinutes,
|
||||
],
|
||||
/** @var string $created_at Date when the report was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
|
||||
@@ -77,6 +77,26 @@ class TimeEntry extends Model implements AuditableContract
|
||||
'still_active_email_sent_at' => 'datetime',
|
||||
];
|
||||
|
||||
public const array SELECT_COLUMNS = [
|
||||
'id',
|
||||
'description',
|
||||
'start',
|
||||
'end',
|
||||
'billable_rate',
|
||||
'billable',
|
||||
'user_id',
|
||||
'organization_id',
|
||||
'project_id',
|
||||
'task_id',
|
||||
'tags',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'member_id',
|
||||
'client_id',
|
||||
'is_imported',
|
||||
'still_active_email_sent_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that are computed. (f.e. for performance reasons)
|
||||
* These attributes can be regenerated at any time.
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Service\Dto;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
use Illuminate\Contracts\Database\Eloquent\Castable;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
@@ -59,6 +60,10 @@ class ReportPropertiesDto implements Castable
|
||||
*/
|
||||
public ?Collection $taskIds = null;
|
||||
|
||||
public ?TimeEntryRoundingType $roundingType = null;
|
||||
|
||||
public ?int $roundingMinutes = null;
|
||||
|
||||
/**
|
||||
* Get the caster class to use when casting from / to this cast target.
|
||||
*
|
||||
@@ -115,6 +120,10 @@ class ReportPropertiesDto implements Castable
|
||||
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
||||
$dto->weekStart = Weekday::from($data->weekStart);
|
||||
$dto->timezone = $data->timezone;
|
||||
// Note: roundingType was added later so it is possible that the value is missing in persisted reports in the DB
|
||||
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;
|
||||
// Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB
|
||||
$dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
|
||||
|
||||
return $dto;
|
||||
}
|
||||
@@ -140,6 +149,8 @@ class ReportPropertiesDto implements Castable
|
||||
'historyGroup' => $value->historyGroup->value,
|
||||
'weekStart' => $value->weekStart->value,
|
||||
'timezone' => $value->timezone,
|
||||
'roundingType' => $value->roundingType?->value,
|
||||
'roundingMinutes' => $value->roundingMinutes,
|
||||
];
|
||||
|
||||
$jsonString = json_encode($data);
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Service;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Client;
|
||||
use App\Models\Project;
|
||||
@@ -41,7 +42,7 @@ class TimeEntryAggregationService
|
||||
* cost: int|null
|
||||
* }
|
||||
*/
|
||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
||||
{
|
||||
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||
$group1Select = null;
|
||||
@@ -56,15 +57,14 @@ class TimeEntryAggregationService
|
||||
}
|
||||
}
|
||||
|
||||
$startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes);
|
||||
$endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes);
|
||||
|
||||
$timeEntriesQuery->selectRaw(
|
||||
($group1Select !== null ? $group1Select.' as group_1,' : '').
|
||||
($group2Select !== null ? $group2Select.' as group_2,' : '').
|
||||
' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'.
|
||||
' round(
|
||||
sum(
|
||||
extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60)
|
||||
)
|
||||
) as cost'
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||
);
|
||||
if ($groupBy !== null) {
|
||||
$timeEntriesQuery->groupBy($groupBy);
|
||||
@@ -164,9 +164,9 @@ class TimeEntryAggregationService
|
||||
* cost: int|null
|
||||
* }
|
||||
*/
|
||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
||||
{
|
||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
|
||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes);
|
||||
|
||||
$keysGroup1 = [];
|
||||
$keysGroup2 = [];
|
||||
|
||||
42
app/Service/TimeEntryService.php
Normal file
42
app/Service/TimeEntryService.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use Illuminate\Support\Carbon;
|
||||
use LogicException;
|
||||
|
||||
class TimeEntryService
|
||||
{
|
||||
public function getStartSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
|
||||
{
|
||||
if ($roundingType === null || $roundingMinutes === null) {
|
||||
return 'start';
|
||||
}
|
||||
if ($roundingMinutes < 1) {
|
||||
throw new LogicException('Rounding minutes must be greater than 0');
|
||||
}
|
||||
|
||||
return 'date_bin(\'1 minutes\', start, TIMESTAMP \'1970-01-01\')';
|
||||
}
|
||||
|
||||
public function getEndSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
|
||||
{
|
||||
if ($roundingType === null || $roundingMinutes === null) {
|
||||
return 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
||||
}
|
||||
if ($roundingMinutes < 1) {
|
||||
throw new LogicException('Rounding minutes must be greater than 0');
|
||||
}
|
||||
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
||||
if ($roundingType === TimeEntryRoundingType::Down) {
|
||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||
} elseif ($roundingType === TimeEntryRoundingType::Up) {
|
||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
|
||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,8 @@
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": [
|
||||
"laravel/telescope"
|
||||
"laravel/telescope",
|
||||
"nwidart/laravel-modules"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Nwidart\Modules\LaravelModulesServiceProvider;
|
||||
|
||||
return [
|
||||
|
||||
@@ -197,6 +198,7 @@ return [
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
App\Providers\JetstreamServiceProvider::class,
|
||||
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
|
||||
LaravelModulesServiceProvider::class,
|
||||
])->toArray(),
|
||||
|
||||
/*
|
||||
|
||||
@@ -153,6 +153,16 @@ class TimeEntryFactory extends Factory
|
||||
});
|
||||
}
|
||||
|
||||
public function endWithDuration(Carbon $end, int $durationInSeconds): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($end, $durationInSeconds): array {
|
||||
return [
|
||||
'start' => $end->copy()->utc()->subSeconds($durationInSeconds),
|
||||
'end' => $end->copy()->utc(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function start(Carbon $start): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($start): array {
|
||||
|
||||
@@ -7,11 +7,8 @@ async function goToProjectsOverview(page: Page) {
|
||||
}
|
||||
|
||||
// Create new project via modal
|
||||
test('test that creating and deleting a new client via the modal works', async ({
|
||||
page,
|
||||
}) => {
|
||||
const newClientName =
|
||||
'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
|
||||
const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Client' }).click();
|
||||
await page.getByPlaceholder('Client Name').fill(newClientName);
|
||||
@@ -28,13 +25,9 @@ test('test that creating and deleting a new client via the modal works', async (
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('client_table')).toContainText(newClientName);
|
||||
const moreButton = page.locator(
|
||||
"[aria-label='Actions for Client " + newClientName + "']"
|
||||
);
|
||||
const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']");
|
||||
moreButton.click();
|
||||
const deleteButton = page.locator(
|
||||
"[aria-label='Delete Client " + newClientName + "']"
|
||||
);
|
||||
const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']");
|
||||
|
||||
await Promise.all([
|
||||
deleteButton.click(),
|
||||
@@ -45,9 +38,7 @@ test('test that creating and deleting a new client via the modal works', async (
|
||||
response.status() === 204
|
||||
),
|
||||
]);
|
||||
await expect(page.getByTestId('client_table')).not.toContainText(
|
||||
newClientName
|
||||
);
|
||||
await expect(page.getByTestId('client_table')).not.toContainText(newClientName);
|
||||
});
|
||||
|
||||
test('test that archiving and unarchiving clients works', async ({ page }) => {
|
||||
|
||||
@@ -22,12 +22,8 @@ test('test that new manager can be invited', async ({ page }) => {
|
||||
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
|
||||
await page.getByRole('button', { name: 'Manager' }).click();
|
||||
await Promise.all([
|
||||
page
|
||||
.getByRole('button', { name: 'Invite Member', exact: true })
|
||||
.click(),
|
||||
expect(page.getByRole('main')).toContainText(
|
||||
`new+${editorId}@editor.test`
|
||||
),
|
||||
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||
expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -38,12 +34,8 @@ test('test that new employee can be invited', async ({ page }) => {
|
||||
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
|
||||
await page.getByRole('button', { name: 'Employee' }).click();
|
||||
await Promise.all([
|
||||
page
|
||||
.getByRole('button', { name: 'Invite Member', exact: true })
|
||||
.click(),
|
||||
await expect(page.getByRole('main')).toContainText(
|
||||
`new+${editorId}@editor.test`
|
||||
),
|
||||
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||
await expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -54,12 +46,8 @@ test('test that new admin can be invited', async ({ page }) => {
|
||||
await page.getByLabel('Email').fill(`new+${adminId}@admin.test`);
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await Promise.all([
|
||||
page
|
||||
.getByRole('button', { name: 'Invite Member', exact: true })
|
||||
.click(),
|
||||
expect(page.getByRole('main')).toContainText(
|
||||
`new+${adminId}@admin.test`
|
||||
),
|
||||
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||
expect(page.getByRole('main')).toContainText(`new+${adminId}@admin.test`),
|
||||
]);
|
||||
});
|
||||
test('test that error shows if no role is selected', async ({ page }) => {
|
||||
@@ -69,9 +57,7 @@ test('test that error shows if no role is selected', async ({ page }) => {
|
||||
|
||||
await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`);
|
||||
await Promise.all([
|
||||
page
|
||||
.getByRole('button', { name: 'Invite Member', exact: true })
|
||||
.click(),
|
||||
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||
expect(page.getByText('Please select a role')).toBeVisible(),
|
||||
]);
|
||||
});
|
||||
@@ -85,9 +71,7 @@ test('test that organization billable rate can be updated with all existing time
|
||||
await page.getByRole('menuitem').getByText('Edit').click();
|
||||
await page.getByText('Organization Default Rate').click();
|
||||
await page.getByText('Custom Rate').click();
|
||||
await page
|
||||
.getByPlaceholder('Billable Rate')
|
||||
.fill(newBillableRate.toString());
|
||||
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
|
||||
await page.getByRole('button', { name: 'Update Member' }).click();
|
||||
|
||||
await Promise.all([
|
||||
@@ -103,8 +87,7 @@ test('test that organization billable rate can be updated with all existing time
|
||||
response.url().includes('/organizations/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200 &&
|
||||
(await response.json()).data.billable_rate ===
|
||||
newBillableRate * 100
|
||||
(await response.json()).data.billable_rate === newBillableRate * 100
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -35,9 +35,9 @@ test('test that organization name can be updated', async ({ page }) => {
|
||||
await page.getByLabel('Organization Name').fill('NEW ORG NAME');
|
||||
await page.getByLabel('Organization Name').press('Enter');
|
||||
await page.getByLabel('Organization Name').press('Meta+r');
|
||||
await expect(
|
||||
page.locator('[data-testid="organization_switcher"]:visible')
|
||||
).toContainText('NEW ORG NAME');
|
||||
await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText(
|
||||
'NEW ORG NAME'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that organization billable rate can be updated with all existing time entries', async ({
|
||||
@@ -46,9 +46,7 @@ test('test that organization billable rate can be updated with all existing time
|
||||
await goToOrganizationSettings(page);
|
||||
const newBillableRate = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Organization Billable Rate').click();
|
||||
await page
|
||||
.getByLabel('Organization Billable Rate')
|
||||
.fill(newBillableRate.toString());
|
||||
await page.getByLabel('Organization Billable Rate').fill(newBillableRate.toString());
|
||||
await page
|
||||
.locator('form')
|
||||
.filter({ hasText: 'Organization Billable' })
|
||||
@@ -56,9 +54,7 @@ test('test that organization billable rate can be updated with all existing time
|
||||
.click();
|
||||
|
||||
await Promise.all([
|
||||
page
|
||||
.getByRole('button', { name: 'Yes, update existing time entries' })
|
||||
.click(),
|
||||
page.getByRole('button', { name: 'Yes, update existing time entries' }).click(),
|
||||
page.waitForRequest(
|
||||
async (request) =>
|
||||
request.url().includes('/organizations/') &&
|
||||
@@ -70,15 +66,12 @@ test('test that organization billable rate can be updated with all existing time
|
||||
response.url().includes('/organizations/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200 &&
|
||||
(await response.json()).data.billable_rate ===
|
||||
newBillableRate * 100
|
||||
(await response.json()).data.billable_rate === newBillableRate * 100
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that organization format settings can be updated', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('test that organization format settings can be updated', async ({ page }) => {
|
||||
await goToOrganizationSettings(page);
|
||||
|
||||
// Test number format
|
||||
@@ -113,8 +106,7 @@ test('test that organization format settings can be updated', async ({
|
||||
response.url().includes('/organizations/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200 &&
|
||||
(await response.json()).data.currency_format ===
|
||||
'iso-code-after-with-space'
|
||||
(await response.json()).data.currency_format === 'iso-code-after-with-space'
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -132,8 +124,7 @@ test('test that organization format settings can be updated', async ({
|
||||
response.url().includes('/organizations/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200 &&
|
||||
(await response.json()).data.date_format ===
|
||||
'slash-separated-dd-mm-yyyy'
|
||||
(await response.json()).data.date_format === 'slash-separated-dd-mm-yyyy'
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -169,19 +160,14 @@ test('test that organization format settings can be updated', async ({
|
||||
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.interval_format === 'hours-minutes-colon-separated'
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that format settings are reflected in the dashboard', async ({
|
||||
page,
|
||||
}) => {
|
||||
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();
|
||||
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).toBeVisible();
|
||||
|
||||
// First set the format settings
|
||||
await goToOrganizationSettings(page);
|
||||
@@ -213,10 +199,8 @@ test('test that format settings are reflected in the dashboard', async ({
|
||||
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.interval_format === 'hours-minutes-colon-separated' &&
|
||||
(await response.json()).data.currency_format === 'symbol-after' &&
|
||||
(await response.json()).data.number_format === 'comma-point'
|
||||
),
|
||||
]);
|
||||
@@ -232,16 +216,12 @@ test('test that format settings are reflected in the dashboard', async ({
|
||||
// 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();
|
||||
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)
|
||||
page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
import {test, expect} from '../playwright/fixtures';
|
||||
import {PLAYWRIGHT_BASE_URL} from '../playwright/config';
|
||||
import { test, expect } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
|
||||
test('test that user name can be updated', async ({page}) => {
|
||||
test('test that user name can be updated', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
await page.getByLabel('Name', {exact: true} ).fill('NEW NAME');
|
||||
await page.getByLabel('Name', { exact: true }).fill('NEW NAME');
|
||||
await Promise.all([
|
||||
page.getByRole('button', {name: 'Save'}).first().click(),
|
||||
page.getByRole('button', { name: 'Save' }).first().click(),
|
||||
page.waitForResponse('**/user/profile-information'),
|
||||
]);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Name', {exact: true})).toHaveValue('NEW NAME');
|
||||
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME');
|
||||
});
|
||||
|
||||
test.skip('test that user email can be updated', async ({page}) => {
|
||||
test.skip('test that user email can be updated', async ({ page }) => {
|
||||
// this does not work because of email verification currently
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
const emailId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
|
||||
await page.getByRole('button', {name: 'Save'}).first().click();
|
||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Email')).toHaveValue(
|
||||
`newemail+${emailId}@test.com`
|
||||
);
|
||||
await expect(page.getByLabel('Email')).toHaveValue(`newemail+${emailId}@test.com`);
|
||||
});
|
||||
|
||||
async function createNewApiToken(page) {
|
||||
await page.getByLabel('API Key Name').fill('NEW API KEY');
|
||||
await Promise.all([
|
||||
page.getByRole('button', {name: 'Create API Key'}).click(),
|
||||
page.waitForResponse('**/users/me/api-tokens')
|
||||
page.getByRole('button', { name: 'Create API Key' }).click(),
|
||||
page.waitForResponse('**/users/me/api-tokens'),
|
||||
]);
|
||||
|
||||
await expect(page.locator('body')).toContainText('API Token created successfully');
|
||||
@@ -36,34 +34,37 @@ async function createNewApiToken(page) {
|
||||
await expect(page.locator('body')).toContainText('NEW API KEY');
|
||||
}
|
||||
|
||||
test('test that user can create an API key', async ({page}) => {
|
||||
test('test that user can create an API key', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
await createNewApiToken(page);
|
||||
});
|
||||
|
||||
test('test that user can delete an API key', async ({page}) => {
|
||||
test('test that user can delete an API key', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
await createNewApiToken(page);
|
||||
page.getByLabel('Delete API Token NEW API KEY').click();
|
||||
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to delete this API token?');
|
||||
await expect(page.getByRole('dialog')).toContainText(
|
||||
'Are you sure you would like to delete this API token?'
|
||||
);
|
||||
await Promise.all([
|
||||
page.getByRole('dialog').getByRole('button', {name: 'Delete'}).click(),
|
||||
page.waitForResponse('**/users/me/api-tokens')
|
||||
page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(),
|
||||
page.waitForResponse('**/users/me/api-tokens'),
|
||||
]);
|
||||
await expect(page.locator('body')).not.toContainText('NEW API KEY');
|
||||
});
|
||||
|
||||
|
||||
test('test that user can revoke an API key', async ({page}) => {
|
||||
test('test that user can revoke an API key', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
await createNewApiToken(page);
|
||||
page.getByLabel('Revoke API Token NEW API KEY').click();
|
||||
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to revoke this API token?');
|
||||
await expect(page.getByRole('dialog')).toContainText(
|
||||
'Are you sure you would like to revoke this API token?'
|
||||
);
|
||||
await Promise.all([
|
||||
page.getByRole('dialog').getByRole('button', {name: 'Revoke'}).click(),
|
||||
page.waitForResponse('**/users/me/api-tokens')
|
||||
page.getByRole('dialog').getByRole('button', { name: 'Revoke' }).click(),
|
||||
page.waitForResponse('**/users/me/api-tokens'),
|
||||
]);
|
||||
await expect(page.getByRole('button', {name: 'Revoke'})).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Revoke' })).toBeHidden();
|
||||
await expect(page.locator('body')).toContainText('NEW API KEY');
|
||||
await expect(page.locator('body')).toContainText('Revoked');
|
||||
});
|
||||
|
||||
@@ -12,8 +12,7 @@ async function goToProjectsOverview(page: Page) {
|
||||
test('test that updating project member billable rate works for existing time entries', async ({
|
||||
page,
|
||||
}) => {
|
||||
const newProjectName =
|
||||
'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const newBillableRate = Math.round(Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
@@ -36,9 +35,7 @@ test('test that updating project member billable rate works for existing time en
|
||||
.first()
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Edit Project Member' })
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
|
||||
await page.getByLabel('Billable Rate').fill(newBillableRate.toString());
|
||||
await page.getByRole('button', { name: 'Update Project Member' }).click();
|
||||
|
||||
@@ -55,8 +52,7 @@ test('test that updating project member billable rate works for existing time en
|
||||
response.url().includes('/project-members/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200 &&
|
||||
(await response.json()).data.billable_rate ===
|
||||
newBillableRate * 100
|
||||
(await response.json()).data.billable_rate === newBillableRate * 100
|
||||
),
|
||||
]);
|
||||
await expect(
|
||||
|
||||
@@ -9,11 +9,8 @@ async function goToProjectsOverview(page: Page) {
|
||||
}
|
||||
|
||||
// Create new project via modal
|
||||
test('test that creating and deleting a new project via the modal works', async ({
|
||||
page,
|
||||
}) => {
|
||||
const newProjectName =
|
||||
'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
|
||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
@@ -31,16 +28,10 @@ test('test that creating and deleting a new project via the modal works', async
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('project_table')).toContainText(
|
||||
newProjectName
|
||||
);
|
||||
const moreButton = page.locator(
|
||||
"[aria-label='Actions for Project " + newProjectName + "']"
|
||||
);
|
||||
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||
const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']");
|
||||
moreButton.click();
|
||||
const deleteButton = page.locator(
|
||||
"[aria-label='Delete Project " + newProjectName + "']"
|
||||
);
|
||||
const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']");
|
||||
|
||||
await Promise.all([
|
||||
deleteButton.click(),
|
||||
@@ -51,14 +42,11 @@ test('test that creating and deleting a new project via the modal works', async
|
||||
response.status() === 204
|
||||
),
|
||||
]);
|
||||
await expect(page.getByTestId('project_table')).not.toContainText(
|
||||
newProjectName
|
||||
);
|
||||
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
|
||||
});
|
||||
|
||||
test('test that archiving and unarchiving projects works', async ({ page }) => {
|
||||
const newProjectName =
|
||||
'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
@@ -87,11 +75,8 @@ test('test that archiving and unarchiving projects works', async ({ page }) => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that updating billable rate works with existing time entries', async ({
|
||||
page,
|
||||
}) => {
|
||||
const newProjectName =
|
||||
'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
test('test that updating billable rate works with existing time entries', async ({ page }) => {
|
||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const newBillableRate = Math.round(Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
@@ -104,15 +89,11 @@ test('test that updating billable rate works with existing time entries', async
|
||||
await page.getByRole('menuitem').getByText('Edit').first().click();
|
||||
await page.getByText('Non-Billable').click();
|
||||
await page.getByText('Custom Rate').click();
|
||||
await page
|
||||
.getByPlaceholder('Billable Rate')
|
||||
.fill(newBillableRate.toString());
|
||||
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
|
||||
await page.getByRole('button', { name: 'Update Project' }).click();
|
||||
|
||||
await Promise.all([
|
||||
page
|
||||
.locator('button').filter({ hasText: 'Yes, update existing time' })
|
||||
.click(),
|
||||
page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),
|
||||
page.waitForRequest(
|
||||
async (request) =>
|
||||
request.url().includes('/projects/') &&
|
||||
@@ -124,8 +105,7 @@ test('test that updating billable rate works with existing time entries', async
|
||||
response.url().includes('/projects/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200 &&
|
||||
(await response.json()).data.billable_rate ===
|
||||
newBillableRate * 100
|
||||
(await response.json()).data.billable_rate === newBillableRate * 100
|
||||
),
|
||||
]);
|
||||
await expect(
|
||||
|
||||
@@ -2,8 +2,6 @@ import { expect, Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
|
||||
|
||||
|
||||
async function goToTimeOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
}
|
||||
@@ -31,7 +29,10 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
||||
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('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill(`Time entry for ${projectName}`);
|
||||
|
||||
await page.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByText(projectName).click();
|
||||
@@ -43,7 +44,9 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
||||
// 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)
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -52,7 +55,10 @@ async function createTimeEntryWithTag(page: Page, tagName: string, duration: str
|
||||
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}`);
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill(`Time entry with tag ${tagName}`);
|
||||
|
||||
// Add tag
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
@@ -69,12 +75,19 @@ async function createTimeEntryWithTag(page: Page, tagName: string, duration: str
|
||||
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||
}
|
||||
|
||||
async function createTimeEntryWithBillableStatus(page: Page, isBillable: boolean, duration: string) {
|
||||
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'}`);
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
|
||||
|
||||
// Set billable status
|
||||
await page.getByRole('button', { name: 'Non-Billable' }).click();
|
||||
@@ -103,19 +116,22 @@ test('test that project filtering works in reporting', async ({ page }) => {
|
||||
// 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 page.getByRole('dialog').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)
|
||||
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();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that tag filtering works in reporting', async ({ page }) => {
|
||||
@@ -138,11 +154,14 @@ test('test that tag filtering works in reporting', async ({ page }) => {
|
||||
// escape
|
||||
page.keyboard.press('Escape'),
|
||||
// wait for API request to finish
|
||||
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
|
||||
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();
|
||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that billable status filtering works in reporting', async ({ page }) => {
|
||||
@@ -160,14 +179,16 @@ test('test that billable status filtering works in reporting', async ({ page })
|
||||
// escape
|
||||
page.keyboard.press('Escape'),
|
||||
// wait for API request to finish
|
||||
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
),
|
||||
]);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').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);
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ async function goToTagsOverview(page: Page) {
|
||||
}
|
||||
|
||||
// Create new project via modal
|
||||
test('test that creating and deleting a new client via the modal works', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
|
||||
const newTagName = 'New Tag ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToTagsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Tag' }).click();
|
||||
@@ -27,13 +25,9 @@ test('test that creating and deleting a new client via the modal works', async (
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('tag_table')).toContainText(newTagName);
|
||||
const moreButton = page.locator(
|
||||
"[aria-label='Actions for Tag " + newTagName + "']"
|
||||
);
|
||||
const moreButton = page.locator("[aria-label='Actions for Tag " + newTagName + "']");
|
||||
moreButton.click();
|
||||
const deleteButton = page.locator(
|
||||
"[aria-label='Delete Tag " + newTagName + "']"
|
||||
);
|
||||
const deleteButton = page.locator("[aria-label='Delete Tag " + newTagName + "']");
|
||||
|
||||
await Promise.all([
|
||||
deleteButton.click(),
|
||||
|
||||
@@ -7,11 +7,8 @@ async function goToProjectsOverview(page: Page) {
|
||||
}
|
||||
|
||||
// Create new project via modal
|
||||
test('test that creating and deleting a new tag in a new project works', async ({
|
||||
page,
|
||||
}) => {
|
||||
const newProjectName =
|
||||
'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
test('test that creating and deleting a new tag in a new project works', async ({ page }) => {
|
||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
@@ -29,9 +26,7 @@ test('test that creating and deleting a new tag in a new project works', async (
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('project_table')).toContainText(
|
||||
newProjectName
|
||||
);
|
||||
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||
|
||||
await page.getByText(newProjectName).click();
|
||||
|
||||
@@ -55,13 +50,9 @@ test('test that creating and deleting a new tag in a new project works', async (
|
||||
|
||||
await expect(page.getByTestId('task_table')).toContainText(newTaskName);
|
||||
|
||||
const taskMoreButton = page.locator(
|
||||
"[aria-label='Actions for Task " + newTaskName + "']"
|
||||
);
|
||||
const taskMoreButton = page.locator("[aria-label='Actions for Task " + newTaskName + "']");
|
||||
taskMoreButton.click();
|
||||
const taskDeleteButton = page.locator(
|
||||
"[aria-label='Delete Task " + newTaskName + "']"
|
||||
);
|
||||
const taskDeleteButton = page.locator("[aria-label='Delete Task " + newTaskName + "']");
|
||||
|
||||
await Promise.all([
|
||||
taskDeleteButton.click(),
|
||||
@@ -76,13 +67,9 @@ test('test that creating and deleting a new tag in a new project works', async (
|
||||
|
||||
await goToProjectsOverview(page);
|
||||
|
||||
const moreButton = page.locator(
|
||||
"[aria-label='Actions for Project " + newProjectName + "']"
|
||||
);
|
||||
const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']");
|
||||
moreButton.click();
|
||||
const deleteButton = page.locator(
|
||||
"[aria-label='Delete Project " + newProjectName + "']"
|
||||
);
|
||||
const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']");
|
||||
|
||||
await Promise.all([
|
||||
deleteButton.click(),
|
||||
@@ -93,14 +80,11 @@ test('test that creating and deleting a new tag in a new project works', async (
|
||||
response.status() === 204
|
||||
),
|
||||
]);
|
||||
await expect(page.getByTestId('project_table')).not.toContainText(
|
||||
newProjectName
|
||||
);
|
||||
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
|
||||
});
|
||||
|
||||
test('test that archiving and unarchiving tasks works', async ({ page }) => {
|
||||
const newProjectName =
|
||||
'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const newTaskName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await goToProjectsOverview(page);
|
||||
|
||||
@@ -25,9 +25,7 @@ async function createEmptyTimeEntry(page: Page) {
|
||||
startOrStopTimerWithButton(page),
|
||||
assertThatTimerIsStopped(page),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 200
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -38,9 +36,7 @@ test('test that starting and stopping an empty time entry shows a new time entry
|
||||
await Promise.all([
|
||||
goToTimeOverview(page),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 200
|
||||
),
|
||||
]);
|
||||
await page.waitForTimeout(100);
|
||||
@@ -56,9 +52,7 @@ test('test that starting and stopping an empty time entry shows a new time entry
|
||||
// Test that description update works
|
||||
|
||||
async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) {
|
||||
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(
|
||||
/bg-accent-300\/70/
|
||||
);
|
||||
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(/bg-accent-300\/70/);
|
||||
}
|
||||
|
||||
test('test that updating a description of a time entry in the overview works on blur', async ({
|
||||
@@ -71,17 +65,14 @@ test('test that updating a description of a time entry in the overview works on
|
||||
await assertThatTimeEntryRowIsStopped(newTimeEntry);
|
||||
|
||||
const newDescription = Math.floor(Math.random() * 1000000).toString();
|
||||
const descriptionElement = newTimeEntry.getByTestId(
|
||||
'time_entry_description'
|
||||
);
|
||||
const descriptionElement = newTimeEntry.getByTestId('time_entry_description');
|
||||
await descriptionElement.fill(newDescription);
|
||||
await Promise.all([
|
||||
descriptionElement.press('Tab'),
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.headerValue('Content-Type')) === 'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
@@ -90,8 +81,7 @@ test('test that updating a description of a time entry in the overview works on
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
JSON.stringify((await response.json()).data.tags) === JSON.stringify([])
|
||||
);
|
||||
}),
|
||||
]);
|
||||
@@ -107,17 +97,14 @@ test('test that updating a description of a time entry in the overview works on
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
await assertThatTimeEntryRowIsStopped(newTimeEntry);
|
||||
const newDescription = Math.floor(Math.random() * 1000000).toString();
|
||||
const descriptionElement = newTimeEntry.getByTestId(
|
||||
'time_entry_description'
|
||||
);
|
||||
const descriptionElement = newTimeEntry.getByTestId('time_entry_description');
|
||||
await descriptionElement.fill(newDescription);
|
||||
await Promise.all([
|
||||
descriptionElement.press('Enter'),
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.headerValue('Content-Type')) === 'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
@@ -126,16 +113,13 @@ test('test that updating a description of a time entry in the overview works on
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
JSON.stringify((await response.json()).data.tags) === JSON.stringify([])
|
||||
);
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that adding a new tag to an existing time entry works', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('test that adding a new tag to an existing time entry works', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await createEmptyTimeEntry(page);
|
||||
@@ -152,8 +136,7 @@ test('test that adding a new tag to an existing time entry works', async ({
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.headerValue('Content-Type')) === 'application/json' &&
|
||||
(await response.json()).data.name === newTagName
|
||||
);
|
||||
}),
|
||||
@@ -163,8 +146,7 @@ test('test that adding a new tag to an existing time entry works', async ({
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.headerValue('Content-Type')) === 'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
@@ -187,17 +169,14 @@ test('test that updating a the start of an existing time entry in the overview w
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
await assertThatTimeEntryRowIsStopped(newTimeEntry);
|
||||
await page.waitForTimeout(1500);
|
||||
const timeEntryRangeElement = newTimeEntry.getByTestId(
|
||||
'time_entry_range_selector'
|
||||
);
|
||||
const timeEntryRangeElement = newTimeEntry.getByTestId('time_entry_range_selector');
|
||||
await timeEntryRangeElement.click();
|
||||
await page.getByTestId('time_entry_range_start').first().fill('1');
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.headerValue('Content-Type')) === 'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
// TODO! Actually check the value
|
||||
(await response.json()).data.start !== null &&
|
||||
@@ -208,9 +187,7 @@ test('test that updating a the start of an existing time entry in the overview w
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that updating a the duration in the overview works on blur', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('test that updating a the duration in the overview works on blur', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await createEmptyTimeEntry(page);
|
||||
@@ -225,8 +202,7 @@ test('test that updating a the duration in the overview works on blur', async ({
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.headerValue('Content-Type')) === 'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
// TODO! Actually check the value
|
||||
(await response.json()).data.start !== null &&
|
||||
@@ -240,9 +216,7 @@ test('test that updating a the duration in the overview works on blur', async ({
|
||||
});
|
||||
|
||||
// Test that start stop button stops running timer
|
||||
test('test that starting a time entry from the overview works', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('test that starting a time entry from the overview works', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await createEmptyTimeEntry(page);
|
||||
@@ -255,8 +229,7 @@ test('test that starting a time entry from the overview works', async ({
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.headerValue('Content-Type')) === 'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null
|
||||
@@ -272,8 +245,7 @@ test('test that starting a time entry from the overview works', async ({
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.headerValue('Content-Type')) === 'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null
|
||||
@@ -284,9 +256,7 @@ test('test that starting a time entry from the overview works', async ({
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that deleting a time entry from the overview works', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('test that deleting a time entry from the overview works', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await createEmptyTimeEntry(page);
|
||||
@@ -302,16 +272,12 @@ test('test that deleting a time entry from the overview works', async ({
|
||||
await expect(timeEntryRows).toHaveCount(0);
|
||||
});
|
||||
|
||||
test.skip('test that load more works when the end of page is reached', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip('test that load more works when the end of page is reached', async ({ page }) => {
|
||||
// this test is flaky when you do not need to scroll
|
||||
await Promise.all([
|
||||
goToTimeOverview(page),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -322,18 +288,14 @@ test.skip('test that load more works when the end of page is reached', async ({
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
response.url().includes('before') &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
JSON.stringify((await response.json()).data) ===
|
||||
JSON.stringify([])
|
||||
(await response.headerValue('Content-Type')) === 'application/json' &&
|
||||
JSON.stringify((await response.json()).data) === JSON.stringify([])
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
// assert that "All time entries are loaded!" is visible on page
|
||||
await expect(page.locator('body')).toHaveText(
|
||||
/All time entries are loaded!/
|
||||
);
|
||||
await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
|
||||
});
|
||||
|
||||
// TODO: Test that updating the time entry start / end times works while it is running
|
||||
|
||||
@@ -24,22 +24,15 @@ test('test that starting and stopping a timer without description and project wo
|
||||
assertThatTimerHasStarted(page),
|
||||
]);
|
||||
await page.waitForTimeout(1500);
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that starting and stopping a timer with a description works', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('test that starting and stopping a timer with a description works', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
// TODO: Fix flakyness by disabling description input field until timer is loaded
|
||||
await page.waitForTimeout(500);
|
||||
await page
|
||||
.getByTestId('time_entry_description')
|
||||
.fill('New Time Entry Description');
|
||||
await page.getByTestId('time_entry_description').fill('New Time Entry Description');
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page, {
|
||||
description: 'New Time Entry Description',
|
||||
@@ -62,47 +55,29 @@ test('test that starting the time entry starts the live timer and that it keeps
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(500);
|
||||
const beforeTimerValue = await page
|
||||
.getByTestId('time_entry_time')
|
||||
.inputValue();
|
||||
const beforeTimerValue = await page.getByTestId('time_entry_time').inputValue();
|
||||
await page.waitForTimeout(2000);
|
||||
const afterWaitTimeValue = await page
|
||||
.getByTestId('time_entry_time')
|
||||
.inputValue();
|
||||
const afterWaitTimeValue = await page.getByTestId('time_entry_time').inputValue();
|
||||
expect(afterWaitTimeValue).not.toEqual(beforeTimerValue);
|
||||
await page.reload();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const afterReloadTimerValue = await page
|
||||
.getByTestId('time_entry_time')
|
||||
.inputValue();
|
||||
const afterReloadTimerValue = await page.getByTestId('time_entry_time').inputValue();
|
||||
await page.waitForTimeout(2000);
|
||||
const afterReloadAfterWaitTimerValue = await page
|
||||
.getByTestId('time_entry_time')
|
||||
.inputValue();
|
||||
const afterReloadAfterWaitTimerValue = await page.getByTestId('time_entry_time').inputValue();
|
||||
expect(afterReloadTimerValue).not.toEqual(afterReloadAfterWaitTimerValue);
|
||||
});
|
||||
|
||||
test('test that starting and updating the description while running works', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('test that starting and updating the description while running works', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(500);
|
||||
await page
|
||||
.getByTestId('time_entry_description')
|
||||
.fill('New Time Entry Description');
|
||||
await page.getByTestId('time_entry_description').fill('New Time Entry Description');
|
||||
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page, {
|
||||
@@ -121,9 +96,7 @@ test('test that starting and updating the description while running works', asyn
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that starting and updating the time while running works', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('test that starting and updating the time while running works', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
const [createResponse] = await Promise.all([
|
||||
newTimeEntryResponse(page),
|
||||
@@ -138,19 +111,16 @@ test('test that starting and updating the time while running works', async ({
|
||||
return (
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.headerValue('Content-Type')) === 'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.start !==
|
||||
(await createResponse.json()).data.start &&
|
||||
(await response.json()).data.start !== (await createResponse.json()).data.start &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
JSON.stringify((await response.json()).data.tags) === JSON.stringify([])
|
||||
);
|
||||
}),
|
||||
page.getByTestId('time_entry_time').press('Enter'),
|
||||
@@ -158,16 +128,11 @@ test('test that starting and updating the time while running works', async ({
|
||||
|
||||
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
|
||||
await page.waitForTimeout(500);
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that entering a human readable time starts the timer on blur', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('test that entering a human readable time starts the timer on blur', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await page.getByTestId('time_entry_time').fill('20min');
|
||||
await Promise.all([
|
||||
@@ -177,18 +142,13 @@ test('test that entering a human readable time starts the timer on blur', async
|
||||
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20:/);
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that entering a number in the time range starts the timer on blur', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('test that entering a number in the time range starts the timer on blur', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await page.getByTestId('time_entry_time').fill('5');
|
||||
await Promise.all([
|
||||
@@ -198,10 +158,7 @@ test('test that entering a number in the time range starts the timer on blur', a
|
||||
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:05:/);
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
|
||||
);
|
||||
@@ -219,10 +176,7 @@ test('test that entering a value with the format hh:mm in the time range starts
|
||||
await expect(page.getByTestId('time_entry_time')).toHaveValue(/12:30:/);
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
|
||||
);
|
||||
@@ -239,9 +193,7 @@ test('test that entering a random value in the time range does not start the tim
|
||||
);
|
||||
});
|
||||
|
||||
test('test that entering a time starts the timer on enter', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('test that entering a time starts the timer on enter', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await page.getByTestId('time_entry_time').fill('20min');
|
||||
await Promise.all([
|
||||
@@ -249,10 +201,7 @@ test('test that entering a time starts the timer on enter', async ({
|
||||
page.getByTestId('time_entry_time').press('Enter'),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
@@ -273,15 +222,10 @@ test('test that adding a new tag works', async ({ page }) => {
|
||||
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that adding a new tag when the timer is running', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('test that adding a new tag when the timer is running', async ({ page }) => {
|
||||
const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);
|
||||
await goToDashboard(page);
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.getByTestId('tag_dropdown').click();
|
||||
await page.getByText('Create new tag').click();
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { expect, Page } from '@playwright/test';
|
||||
|
||||
export async function startOrStopTimerWithButton(page: Page) {
|
||||
await page
|
||||
.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
|
||||
.click();
|
||||
await page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]').click();
|
||||
}
|
||||
|
||||
export async function assertThatTimerHasStarted(page: Page) {
|
||||
@@ -20,8 +18,7 @@ export function newTimeEntryResponse(
|
||||
return (
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === status &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.headerValue('Content-Type')) === 'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
@@ -29,30 +26,23 @@ export function newTimeEntryResponse(
|
||||
(await response.json()).data.description === description &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify(tags)
|
||||
JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function assertThatTimerIsStopped(page: Page) {
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"]'
|
||||
)
|
||||
page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
|
||||
).toHaveClass(/bg-accent-300\/70/);
|
||||
}
|
||||
|
||||
export async function stoppedTimeEntryResponse(
|
||||
page: Page,
|
||||
{ description = '', tags = [] } = {}
|
||||
) {
|
||||
export async function stoppedTimeEntryResponse(page: Page, { description = '', tags = [] } = {}) {
|
||||
return page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
response.url().includes('/time-entries/') &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.headerValue('Content-Type')) === 'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
@@ -61,8 +51,7 @@ export async function stoppedTimeEntryResponse(
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify(tags)
|
||||
JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,4 +14,4 @@ export function formatCentsWithOrganizationDefaults(
|
||||
currencySymbol,
|
||||
'point-comma' as NumberFormat
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ export function newTagResponse(page: Page, { name = '' } = {}) {
|
||||
return page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.headerValue('Content-Type')) === 'application/json' &&
|
||||
(await response.json()).data.name === name
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
import eslintPluginVue from 'eslint-plugin-vue';
|
||||
import globals from 'globals';
|
||||
import typescriptEslint from 'typescript-eslint';
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
import unusedImports from 'eslint-plugin-unused-imports';
|
||||
|
||||
export default typescriptEslint.config(
|
||||
{ ignores: ['*.d.ts', '**/coverage', '**/dist'] },
|
||||
@@ -23,18 +23,21 @@ export default typescriptEslint.config(
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"unused-imports": unusedImports,
|
||||
'unused-imports': unusedImports,
|
||||
},
|
||||
rules: {
|
||||
"vue/multi-word-component-names": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": ["error", {
|
||||
"vars": "all",
|
||||
"varsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"argsIgnorePattern": "^_",
|
||||
}],
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
'vars': 'all',
|
||||
'varsIgnorePattern': '^_',
|
||||
'args': 'after-used',
|
||||
'argsIgnorePattern': '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -16,7 +16,7 @@
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
"lint:fix": "eslint --fix resources/js",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test:e2e": "rm -rf test-results/.auth && npx playwright test",
|
||||
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api"
|
||||
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api",
|
||||
"format": "prettier --write './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'",
|
||||
"format:check": "prettier --check './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@@ -46,7 +48,7 @@
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export const PLAYWRIGHT_BASE_URL =
|
||||
process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
|
||||
export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
|
||||
|
||||
@@ -8,12 +8,8 @@ export const test = baseTest.extend<object, { workerStorageState: string }>({
|
||||
// Perform authentication steps. Replace these actions with your own.
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||
await page.getByLabel('Name').fill('John Doe');
|
||||
await page
|
||||
.getByLabel('Email')
|
||||
.fill(`john+${Math.round(Math.random() * 1000000)}@doe.com`);
|
||||
await page
|
||||
.getByLabel('Password', { exact: true })
|
||||
.fill('amazingpassword123');
|
||||
await page.getByLabel('Email').fill(`john+${Math.round(Math.random() * 1000000)}@doe.com`);
|
||||
await page.getByLabel('Password', { exact: true }).fill('amazingpassword123');
|
||||
await page.getByLabel('Confirm Password').fill('amazingpassword123');
|
||||
await page.getByLabel('I agree to the Terms of').click();
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
2052
public/fonts/Inter-Variable.ttf
Normal file
2052
public/fonts/Inter-Variable.ttf
Normal file
File diff suppressed because one or more lines are too long
2052
public/fonts/Inter-Variable.woff2
Normal file
2052
public/fonts/Inter-Variable.woff2
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -160,30 +160,17 @@ body {
|
||||
}
|
||||
|
||||
|
||||
/* Inter Variable Font with browser compatibility considerations */
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
src: url('/fonts/Outfit-Regular.ttf');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
src: url('/fonts/Outfit-Medium.ttf');
|
||||
font-weight: 500;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
src: url('/fonts/Outfit-SemiBold.ttf');
|
||||
font-weight: 600;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
src: url('/fonts/Outfit-Bold.ttf');
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
src: url('/fonts/Outfit-ExtraBold.ttf');
|
||||
font-weight: 800;
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/Inter-Variable.woff2') format('woff2 supports variations'),
|
||||
url('/fonts/Inter-Variable.woff2') format('woff2-variations'),
|
||||
url('/fonts/Inter-Variable.ttf') format('truetype supports variations'),
|
||||
url('/fonts/Inter-Variable.ttf') format('truetype-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -205,7 +192,7 @@ body {
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--theme-color-input-background);
|
||||
--input: var(--color-border-tertiary);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-400);
|
||||
--chart-2: var(--color-accent-500);
|
||||
@@ -232,7 +219,7 @@ body {
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--theme-color-input-background);
|
||||
--input: var(--color-border-tertiary);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-200);
|
||||
--chart-2: var(--color-accent-300);
|
||||
|
||||
@@ -14,8 +14,7 @@ import SectionTitle from './SectionTitle.vue';
|
||||
</SectionTitle>
|
||||
|
||||
<div class="mt-5 md:mt-0 md:col-span-2">
|
||||
<div
|
||||
class="px-4 py-5 sm:p-6 bg-card-background shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6 bg-card-background shadow sm:rounded-lg">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { useTheme } from "@/utils/theme.js";
|
||||
import { onMounted } from 'vue';
|
||||
import { useTheme } from '@/utils/theme.js';
|
||||
|
||||
onMounted(async () => {
|
||||
useTheme()
|
||||
useTheme();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -24,9 +24,7 @@ watchEffect(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="show && message"
|
||||
class="bg-secondary border-b border-border-secondary">
|
||||
<div v-if="show && message" class="bg-secondary border-b border-border-secondary">
|
||||
<div class="mx-auto py-1 px-3 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between flex-wrap">
|
||||
<div class="w-0 flex-1 flex items-center min-w-0">
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import {
|
||||
CheckBadgeIcon,
|
||||
XMarkIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/vue/16/solid';
|
||||
import { CheckBadgeIcon, XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
@@ -18,28 +14,20 @@ import { useSessionStorage } from '@vueuse/core';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { canManageBilling } from '@/utils/permissions';
|
||||
|
||||
const hideTrialBanner = useSessionStorage(
|
||||
'showTrialBanner-' + getCurrentOrganizationId(),
|
||||
false
|
||||
);
|
||||
const hideTrialBanner = useSessionStorage('showTrialBanner-' + getCurrentOrganizationId(), false);
|
||||
const showTrialBanner = computed(() => isInTrial() && !hideTrialBanner.value);
|
||||
const hideBlockedBanner = useSessionStorage(
|
||||
'showBlockedBanner-' + getCurrentOrganizationId(),
|
||||
false
|
||||
);
|
||||
const showBlockedBanner = computed(
|
||||
() => isBlocked() && !hideBlockedBanner.value
|
||||
);
|
||||
const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value);
|
||||
const hideFreeUpgradeBanner = useSessionStorage(
|
||||
'showFreeUpgradeBanner-' + getCurrentOrganizationId(),
|
||||
false
|
||||
);
|
||||
const showFreeUpgradeBanner = computed(
|
||||
() =>
|
||||
isFreePlan() &&
|
||||
!isBlocked() &&
|
||||
!hideFreeUpgradeBanner.value &&
|
||||
!showBlackFridayBanner.value
|
||||
isFreePlan() && !isBlocked() && !hideFreeUpgradeBanner.value && !showBlackFridayBanner.value
|
||||
);
|
||||
const hideBlackFridayBanner = useSessionStorage(
|
||||
'hideBlackFridayBanner-' + getCurrentOrganizationId(),
|
||||
@@ -62,10 +50,7 @@ const showBlackFridayBanner = computed(() => {
|
||||
class="bg-tertiary text-xs lg:text-sm pb-1 pt-2 border-b border-border-secondary">
|
||||
<MainContainer class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<svg
|
||||
class="w-4 mr-1"
|
||||
viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<svg class="w-4 mr-1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="#FF37AD"
|
||||
d="M22.498 68.97a11.845 11.845 0 1 0 0-23.687c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844m181.393-10.04a11.845 11.845 0 1 0-.003-23.688c-6.471.098-11.665 5.373-11.665 11.845c.001 6.472 5.197 11.745 11.668 11.842" />
|
||||
@@ -113,8 +98,7 @@ const showBlackFridayBanner = computed(() => {
|
||||
</div>
|
||||
</Link>
|
||||
<button class="p-1" @click="hideBlackFridayBanner = true">
|
||||
<XMarkIcon
|
||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
</div>
|
||||
</MainContainer>
|
||||
@@ -130,8 +114,8 @@ const showBlackFridayBanner = computed(() => {
|
||||
Your trial expires in {{ daysLeftInTrial() }} days.
|
||||
</span>
|
||||
<span class="hidden md:inline">
|
||||
To continue using all features & support the development
|
||||
of solidtime, please upgrade your plan.
|
||||
To continue using all features & support the development of solidtime,
|
||||
please upgrade your plan.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,8 +127,7 @@ const showBlackFridayBanner = computed(() => {
|
||||
</div>
|
||||
</Link>
|
||||
<button class="p-1" @click="hideTrialBanner = true">
|
||||
<XMarkIcon
|
||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
</div>
|
||||
</MainContainer>
|
||||
@@ -156,27 +139,22 @@ const showBlackFridayBanner = computed(() => {
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
|
||||
<div class="flex-1 space-x-1">
|
||||
<span class="font-medium">
|
||||
Your organization is currently blocked.
|
||||
</span>
|
||||
<span class="font-medium"> Your organization is currently blocked. </span>
|
||||
<span class="hidden md:inline">
|
||||
Please upgrade to a premium plan or remove all users
|
||||
except the owner to unblock your organization.
|
||||
Please upgrade to a premium plan or remove all users except the owner to
|
||||
unblock your organization.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link
|
||||
v-if="isBillingActivated() && canManageBilling()"
|
||||
href="/billing">
|
||||
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
|
||||
<div
|
||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button class="p-1" @click="hideBlockedBanner = true">
|
||||
<XMarkIcon
|
||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
</div>
|
||||
</MainContainer>
|
||||
@@ -188,27 +166,22 @@ const showBlackFridayBanner = computed(() => {
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
|
||||
<div class="flex-1 space-x-1">
|
||||
<span class="font-medium">
|
||||
You are currently using the Free Plan.
|
||||
</span>
|
||||
<span class="font-medium"> You are currently using the Free Plan. </span>
|
||||
<span class="hidden md:inline">
|
||||
To unlock all premium features & support the development
|
||||
of solidtime, please upgrade your plan.</span
|
||||
To unlock all premium features & support the development of solidtime,
|
||||
please upgrade your plan.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link
|
||||
v-if="isBillingActivated() && canManageBilling()"
|
||||
href="/billing">
|
||||
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
|
||||
<div
|
||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button class="p-1" @click="hideFreeUpgradeBanner = true">
|
||||
<XMarkIcon
|
||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
</div>
|
||||
</MainContainer>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border overflow-hidden border-card-border bg-card-background shadow-card">
|
||||
<div
|
||||
class="rounded-lg border overflow-hidden border-card-border bg-card-background shadow-card">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArchiveBoxIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import { ArchiveBoxIcon, PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';
|
||||
import type { Client } from '@/packages/api/src';
|
||||
import { canDeleteClients, canUpdateClients } from '@/utils/permissions';
|
||||
import {
|
||||
|
||||
@@ -24,17 +24,10 @@ const createClient = ref(false);
|
||||
class="grid min-w-full"
|
||||
style="grid-template-columns: 1fr 150px 200px 80px">
|
||||
<ClientTableHeading></ClientTableHeading>
|
||||
<div
|
||||
v-if="clients.length === 0"
|
||||
class="col-span-3 py-24 text-center">
|
||||
<UserCircleIcon
|
||||
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
|
||||
<h3 class="text-text-primary font-semibold">
|
||||
No clients found
|
||||
</h3>
|
||||
<p v-if="canCreateClients()" class="pb-5">
|
||||
Create your first client now!
|
||||
</p>
|
||||
<div v-if="clients.length === 0" class="col-span-3 py-24 text-center">
|
||||
<UserCircleIcon class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
|
||||
<h3 class="text-text-primary font-semibold">No clients found</h3>
|
||||
<p v-if="canCreateClients()" class="pb-5">Create your first client now!</p>
|
||||
<SecondaryButton
|
||||
v-if="canCreateClients()"
|
||||
:icon="PlusIcon as Component"
|
||||
|
||||
@@ -20,9 +20,7 @@ function deleteClient() {
|
||||
}
|
||||
|
||||
const projectCount = computed(() => {
|
||||
return projects.value.filter(
|
||||
(projects) => projects.client_id === props.client.id
|
||||
).length;
|
||||
return projects.value.filter((projects) => projects.client_id === props.client.id).length;
|
||||
});
|
||||
|
||||
function archiveClient() {
|
||||
@@ -37,9 +35,7 @@ const showEditModal = ref(false);
|
||||
|
||||
<template>
|
||||
<TableRow>
|
||||
<ClientEditModal
|
||||
v-model:show="showEditModal"
|
||||
:client="client"></ClientEditModal>
|
||||
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
|
||||
<div
|
||||
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<span>
|
||||
|
||||
@@ -20,11 +20,8 @@ onMounted(async () => {
|
||||
class="grid min-w-full"
|
||||
style="grid-template-columns: 1fr 1fr 80px">
|
||||
<InvitationTableHeading></InvitationTableHeading>
|
||||
<template
|
||||
v-for="invitation in invitations"
|
||||
:key="invitation.id">
|
||||
<InvitationTableRow
|
||||
:invitation="invitation"></InvitationTableRow>
|
||||
<template v-for="invitation in invitations" :key="invitation.id">
|
||||
<InvitationTableRow :invitation="invitation"></InvitationTableRow>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,7 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
Email
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
|
||||
<div
|
||||
class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
</TableHeading>
|
||||
|
||||
@@ -38,15 +38,12 @@ async function resendInvitation() {
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.resendInvitationEmail(
|
||||
undefined,
|
||||
{
|
||||
params: {
|
||||
invitation: props.invitation.id,
|
||||
organization: organizationId,
|
||||
},
|
||||
}
|
||||
),
|
||||
api.resendInvitationEmail(undefined, {
|
||||
params: {
|
||||
invitation: props.invitation.id,
|
||||
organization: organizationId,
|
||||
},
|
||||
}),
|
||||
'Invitation mail sent successfully',
|
||||
'Error sending invitation mail'
|
||||
);
|
||||
@@ -65,9 +62,7 @@ async function resendInvitation() {
|
||||
</div>
|
||||
<div
|
||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<InvitationMoreOptionsDropdown
|
||||
@delete="deleteInvitation"
|
||||
@resend="resendInvitation" />
|
||||
<InvitationMoreOptionsDropdown @delete="deleteInvitation" @resend="resendInvitation" />
|
||||
</div>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
@@ -42,8 +42,8 @@ defineEmits<{
|
||||
>.
|
||||
</p>
|
||||
<p class="py-1 text-center font-semibold max-w-md mx-auto">
|
||||
Do you want to update all existing time entries, where the member
|
||||
billable rate applies as well?
|
||||
Do you want to update all existing time entries, where the member billable rate applies
|
||||
as well?
|
||||
</p>
|
||||
</BillableRateModal>
|
||||
</template>
|
||||
|
||||
@@ -35,12 +35,8 @@ useFocus(searchInput, { initialValue: true });
|
||||
const filteredMembers = computed<Member[]>(() => {
|
||||
return members.value.filter((member) => {
|
||||
return (
|
||||
member.name
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '') &&
|
||||
!props.hiddenMembers.some(
|
||||
(hiddenMember) => hiddenMember.member_id === member.id
|
||||
) &&
|
||||
member.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '') &&
|
||||
!props.hiddenMembers.some((hiddenMember) => hiddenMember.member_id === member.id) &&
|
||||
member.is_placeholder === false
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import InputError from '@/packages/ui/src/Input/InputError.vue';
|
||||
import { useMembersStore } from '@/utils/useMembers';
|
||||
|
||||
@@ -30,7 +30,7 @@ const deleteMutation = useMutation({
|
||||
if (!organizationId) {
|
||||
throw new Error('No organization ID found');
|
||||
}
|
||||
|
||||
|
||||
return api.removeMember(undefined, {
|
||||
params: {
|
||||
member: props.member.id,
|
||||
@@ -44,7 +44,7 @@ const deleteMutation = useMutation({
|
||||
onSuccess: () => {
|
||||
close();
|
||||
useMembersStore().fetchMembers();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
@@ -70,77 +70,77 @@ const close = () => {
|
||||
<template>
|
||||
<Modal :show="show" max-width="md" @close="close">
|
||||
<div class="p-6">
|
||||
<h2 class="text-lg font-medium text-text-primary">
|
||||
Delete Member
|
||||
</h2>
|
||||
<h2 class="text-lg font-medium text-text-primary">Delete Member</h2>
|
||||
|
||||
<div class="mt-4 text-sm text-text-secondary">
|
||||
<p class="mb-4">
|
||||
Are you sure you want to delete {{ member.name }}? This action cannot be undone.
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
This will permanently delete:
|
||||
</p>
|
||||
<p class="mb-4">This will permanently delete:</p>
|
||||
|
||||
<ul class="list-disc ml-6 mt-2">
|
||||
<li>All time entries created by this member</li>
|
||||
<li>Their project assignments</li>
|
||||
<li>Their organization membership</li>
|
||||
</ul>
|
||||
<ul class="list-disc ml-6 mt-2">
|
||||
<li>All time entries created by this member</li>
|
||||
<li>Their project assignments</li>
|
||||
<li>Their organization membership</li>
|
||||
</ul>
|
||||
<p class="pt-4">
|
||||
<strong>Note:</strong> Deleting time entries will affect all reports and statistics.
|
||||
If you want to keep the time entries but remove the member from your organization, you can convert them to a placeholder user instead. Placeholder users are not charged and their time entries remain intact for reporting purposes.
|
||||
<strong>Note:</strong> Deleting time entries will affect all reports and
|
||||
statistics. If you want to keep the time entries but remove the member from your
|
||||
organization, you can convert them to a placeholder user instead. Placeholder
|
||||
users are not charged and their time entries remain intact for reporting
|
||||
purposes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="mt-6" @submit="
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}
|
||||
">
|
||||
class="mt-6"
|
||||
@submit="
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}
|
||||
">
|
||||
<div class="flex items-start">
|
||||
<form.Field
|
||||
name="confirmDelete"
|
||||
:validators="{
|
||||
onSubmit: ({value}) => {
|
||||
if (!value) {
|
||||
return 'You must confirm that you understand the consequences of this action';
|
||||
}
|
||||
return '';
|
||||
<form.Field
|
||||
name="confirmDelete"
|
||||
:validators="{
|
||||
onSubmit: ({ value }) => {
|
||||
if (!value) {
|
||||
return 'You must confirm that you understand the consequences of this action';
|
||||
}
|
||||
}"
|
||||
>
|
||||
return '';
|
||||
},
|
||||
}">
|
||||
<template #default="{ field }">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center space-x-3 text-sm">
|
||||
<Checkbox
|
||||
:id="field.name"
|
||||
:name="field.name"
|
||||
:checked="field.state.value"
|
||||
@update:checked="field.handleChange"
|
||||
@blur="field.handleBlur"
|
||||
/>
|
||||
<InputLabel :for="field.name" class="font-medium text-text-primary">
|
||||
I understand that this will permanently delete all data related to this member
|
||||
</InputLabel>
|
||||
</div>
|
||||
<InputError class="pl-7 pt-2" :message="field.state.meta.errors[0]" />
|
||||
<div class="flex items-center space-x-3 text-sm">
|
||||
<Checkbox
|
||||
:id="field.name"
|
||||
:name="field.name"
|
||||
:checked="field.state.value"
|
||||
@update:checked="field.handleChange"
|
||||
@blur="field.handleBlur" />
|
||||
<InputLabel
|
||||
:for="field.name"
|
||||
class="font-medium text-text-primary">
|
||||
I understand that this will permanently delete all data
|
||||
related to this member
|
||||
</InputLabel>
|
||||
</div>
|
||||
<InputError
|
||||
class="pl-7 pt-2"
|
||||
:message="field.state.meta.errors[0]" />
|
||||
</div>
|
||||
</template>
|
||||
</form.Field>
|
||||
</form.Field>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<SecondaryButton @click="close">Cancel</SecondaryButton>
|
||||
<form.Subscribe>
|
||||
<template #default="{ canSubmit, isSubmitting }">
|
||||
<DangerButton
|
||||
type="submit"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
{{ isSubmitting ? 'Deleting...' : 'Delete Member' }}
|
||||
<DangerButton type="submit" :disabled="!canSubmit">
|
||||
{{ isSubmitting ? 'Deleting...' : 'Delete Member' }}
|
||||
</DangerButton>
|
||||
</template>
|
||||
</form.Subscribe>
|
||||
@@ -148,4 +148,4 @@ class="mt-6" @submit="
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -54,10 +54,7 @@ function saveWithChecks() {
|
||||
showBillableRateModal.value = true;
|
||||
}, 0);
|
||||
show.value = false;
|
||||
} else if (
|
||||
memberBody.value.role === 'owner' &&
|
||||
props.member.role !== 'owner'
|
||||
) {
|
||||
} else if (memberBody.value.role === 'owner' && props.member.role !== 'owner') {
|
||||
show.value = false;
|
||||
showOwnershipTransferConfirmModal.value = true;
|
||||
} else {
|
||||
@@ -96,10 +93,7 @@ const roleDescriptionTexts = {
|
||||
};
|
||||
|
||||
const roleDescription = computed(() => {
|
||||
if (
|
||||
memberBody.value.role &&
|
||||
memberBody.value.role in roleDescriptionTexts
|
||||
) {
|
||||
if (memberBody.value.role && memberBody.value.role in roleDescriptionTexts) {
|
||||
return roleDescriptionTexts[memberBody.value.role];
|
||||
}
|
||||
return '';
|
||||
@@ -143,22 +137,14 @@ const roleDescription = computed(() => {
|
||||
<div>
|
||||
<InputLabel for="billableType" value="Billable" />
|
||||
<MemberBillableSelect
|
||||
v-model="
|
||||
billableRateSelect
|
||||
"
|
||||
v-model="billableRateSelect"
|
||||
class="mt-2"
|
||||
name="billableType"></MemberBillableSelect>
|
||||
</div>
|
||||
<div
|
||||
v-if="billableRateSelect === 'custom-rate'"
|
||||
class="flex-1">
|
||||
<InputLabel
|
||||
for="memberBillableRate"
|
||||
value="Billable Rate" />
|
||||
<div v-if="billableRateSelect === 'custom-rate'" class="flex-1">
|
||||
<InputLabel for="memberBillableRate" value="Billable Rate" />
|
||||
<BillableRateInput
|
||||
v-model="
|
||||
memberBody.billable_rate
|
||||
"
|
||||
v-model="memberBody.billable_rate"
|
||||
focus
|
||||
class="w-full"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
|
||||
@@ -11,10 +11,7 @@ import type { Role } from '@/types/jetstream';
|
||||
import { Link, useForm } from '@inertiajs/vue3';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { filterRoles } from '@/utils/roles';
|
||||
import {
|
||||
isAllowedToPerformPremiumAction,
|
||||
isBillingActivated,
|
||||
} from '@/utils/billing';
|
||||
import { isAllowedToPerformPremiumAction, isBillingActivated } from '@/utils/billing';
|
||||
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
|
||||
import { api } from '@/packages/api/src';
|
||||
@@ -44,14 +41,10 @@ const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
|
||||
async function submit() {
|
||||
if (addTeamMemberForm.role === null || addTeamMemberForm.email === '') {
|
||||
errors.value.email = z
|
||||
.string()
|
||||
.email()
|
||||
.safeParse(addTeamMemberForm.email).success
|
||||
errors.value.email = z.string().email().safeParse(addTeamMemberForm.email).success
|
||||
? ''
|
||||
: 'Please enter a valid email address';
|
||||
errors.value.role =
|
||||
addTeamMemberForm.role === null ? 'Please select a role' : '';
|
||||
errors.value.role = addTeamMemberForm.role === null ? 'Please select a role' : '';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,21 +93,15 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
<UserGroupIcon class="w-12"></UserGroupIcon>
|
||||
</div>
|
||||
<div class="max-w-sm text-center mx-auto py-4 text-base">
|
||||
<p class="py-1">
|
||||
The Free plan is <strong>limited to one member</strong>
|
||||
</p>
|
||||
<p class="py-1">The Free plan is <strong>limited to one member</strong></p>
|
||||
<p class="py-1">
|
||||
To add new team members to your organization you,
|
||||
<strong>please upgrade to a paid plan</strong>.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
v-if="isBillingActivated() && canManageBilling()"
|
||||
href="/billing">
|
||||
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
|
||||
<PrimaryButton
|
||||
v-if="
|
||||
isBillingActivated() && canUpdateOrganization()
|
||||
"
|
||||
v-if="isBillingActivated() && canUpdateOrganization()"
|
||||
type="button"
|
||||
class="mt-6">
|
||||
<CreditCardIcon class="w-5 h-5 me-2" />
|
||||
@@ -154,8 +141,7 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
:class="{
|
||||
'border-t border-card-border focus:border-none rounded-t-none':
|
||||
i > 0,
|
||||
'rounded-b-none':
|
||||
i != Object.keys(availableRoles).length - 1,
|
||||
'rounded-b-none': i != Object.keys(availableRoles).length - 1,
|
||||
}"
|
||||
@click="addTeamMemberForm.role = role.key">
|
||||
<div
|
||||
@@ -169,17 +155,13 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
<div
|
||||
class="text-sm text-text-primary"
|
||||
:class="{
|
||||
'font-semibold':
|
||||
addTeamMemberForm.role ==
|
||||
role.key,
|
||||
'font-semibold': addTeamMemberForm.role == role.key,
|
||||
}">
|
||||
{{ role.name }}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
v-if="
|
||||
addTeamMemberForm.role == role.key
|
||||
"
|
||||
v-if="addTeamMemberForm.role == role.key"
|
||||
class="ms-2 h-5 w-5 text-green-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import {ref} from 'vue';
|
||||
import {api, type Member} from '@/packages/api/src';
|
||||
import { ref } from 'vue';
|
||||
import { api, type Member } from '@/packages/api/src';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import {useMutation} from '@tanstack/vue-query';
|
||||
import {getCurrentOrganizationId} from "@/utils/useUser";
|
||||
import {useNotificationsStore} from "@/utils/notification";
|
||||
import {useMembersStore} from "@/utils/useMembers";
|
||||
import { useMutation } from '@tanstack/vue-query';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
import { useMembersStore } from '@/utils/useMembers';
|
||||
|
||||
const {handleApiRequestNotifications} = useNotificationsStore();
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
|
||||
const show = defineModel('show', {default: false});
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -27,7 +27,7 @@ const turnToPlaceholderMutation = useMutation({
|
||||
return await api.makePlaceholder(undefined, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
member: props.member.id
|
||||
member: props.member.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -36,17 +36,15 @@ const turnToPlaceholderMutation = useMutation({
|
||||
async function submit() {
|
||||
saving.value = true;
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
turnToPlaceholderMutation.mutateAsync(),
|
||||
() => turnToPlaceholderMutation.mutateAsync(),
|
||||
'Deactivating the member was successful!',
|
||||
'There was an error deactivating the user.',
|
||||
() => {
|
||||
show.value = false;
|
||||
useMembersStore().fetchMembers()
|
||||
useMembersStore().fetchMembers();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -59,8 +57,9 @@ async function submit() {
|
||||
|
||||
<template #content>
|
||||
<p>
|
||||
Deactivating the user <strong>{{ member.name }} </strong> will remove the user's access to
|
||||
the organization. You will not be billed for inactive users and all time entries will be preserved.
|
||||
Deactivating the user <strong>{{ member.name }} </strong> will remove the user's
|
||||
access to the organization. You will not be billed for inactive users and all time
|
||||
entries will be preserved.
|
||||
</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { ref } from 'vue';
|
||||
import {api, type Member} from '@/packages/api/src';
|
||||
import { api, type Member } from '@/packages/api/src';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import MemberCombobox from "@/Components/Common/Member/MemberCombobox.vue";
|
||||
import {UserIcon, ArrowRightIcon} from "@heroicons/vue/24/solid";
|
||||
import {Badge} from "@/packages/ui/src";
|
||||
import MemberCombobox from '@/Components/Common/Member/MemberCombobox.vue';
|
||||
import { UserIcon, ArrowRightIcon } from '@heroicons/vue/24/solid';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import { useMutation } from '@tanstack/vue-query';
|
||||
import {getCurrentOrganizationId} from "@/utils/useUser";
|
||||
import {useNotificationsStore} from "@/utils/notification";
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
const { handleApiRequestNotifications, addNotification } = useNotificationsStore();
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
@@ -27,40 +27,36 @@ const mergeMember = useMutation({
|
||||
if (organizationId === null) {
|
||||
throw new Error('No current organization id - create report');
|
||||
}
|
||||
return await api.mergeMember({
|
||||
member_id: newMemberId,
|
||||
}, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
member: props.member.id
|
||||
return await api.mergeMember(
|
||||
{
|
||||
member_id: newMemberId,
|
||||
},
|
||||
});
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
member: props.member.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
const newMemberId = newMember.value;
|
||||
if(newMemberId !== ''){
|
||||
if (newMemberId !== '') {
|
||||
saving.value = true;
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
mergeMember.mutateAsync(newMemberId),
|
||||
() => mergeMember.mutateAsync(newMemberId),
|
||||
'Members successfully merged!',
|
||||
'There was an error merging the members.',
|
||||
() => {
|
||||
show.value = false;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
addNotification('error', 'Please select a member to merge into.');
|
||||
}
|
||||
else{
|
||||
addNotification(
|
||||
'error',
|
||||
'Please select a member to merge into.',
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -72,10 +68,14 @@ async function submit() {
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<p>Merging the user <strong>{{ member.name }} </strong> into another one will transfer all time entries to the new user. <strong>This cannot be reverted!</strong></p>
|
||||
<p>
|
||||
Merging the user <strong>{{ member.name }} </strong> into another one will transfer
|
||||
all time entries to the new user. <strong>This cannot be reverted!</strong>
|
||||
</p>
|
||||
<div class="py-5 flex flex-col md:flex-row gap-6 items-center">
|
||||
<div class="flex-1">
|
||||
<Badge class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
|
||||
<Badge
|
||||
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
|
||||
<UserIcon class="relative z-10 w-4 text-text-secondary"></UserIcon>
|
||||
<div class="flex-1 font-medium truncate">
|
||||
{{ member.name }}
|
||||
@@ -86,9 +86,7 @@ async function submit() {
|
||||
<ArrowRightIcon class="relative z-10 w-4 text-muted"></ArrowRightIcon>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<MemberCombobox
|
||||
v-model="newMember"
|
||||
></MemberCombobox>
|
||||
<MemberCombobox v-model="newMember"></MemberCombobox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, UserCircleIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid';
|
||||
import {
|
||||
TrashIcon,
|
||||
UserCircleIcon,
|
||||
PencilSquareIcon,
|
||||
ArrowDownOnSquareStackIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import type { Member } from '@/packages/api/src';
|
||||
import {canDeleteMembers, canMakeMembersPlaceholders, canMergeMembers, canUpdateMembers} from '@/utils/permissions';
|
||||
import {
|
||||
canDeleteMembers,
|
||||
canMakeMembersPlaceholders,
|
||||
canMergeMembers,
|
||||
canUpdateMembers,
|
||||
} from '@/utils/permissions';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
||||
@@ -26,8 +26,8 @@ const emit = defineEmits<{
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="col-span-6 sm:col-span-4 flex-1">
|
||||
<p class="py-1 text-center">
|
||||
You are about to transfer the ownership of this
|
||||
organization to {{ memberName }}.
|
||||
You are about to transfer the ownership of this organization to
|
||||
{{ memberName }}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,9 +22,7 @@ function getNameFromItem(item: Role) {
|
||||
}
|
||||
|
||||
function getNameForKey(key: string | undefined) {
|
||||
const item = page.props.availableRoles.find(
|
||||
(item) => getKeyFromItem(item) === key
|
||||
);
|
||||
const item = page.props.availableRoles.find((item) => getKeyFromItem(item) === key);
|
||||
if (item) {
|
||||
return getNameFromItem(item);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,9 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Email</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
||||
Billable Rate
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
|
||||
<div
|
||||
class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
</TableHeading>
|
||||
|
||||
@@ -86,13 +86,9 @@ const userHasValidMailAddress = computed(() => {
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
|
||||
<CheckCircleIcon
|
||||
v-if="member.is_placeholder === false"
|
||||
class="w-5"></CheckCircleIcon>
|
||||
<CheckCircleIcon v-if="member.is_placeholder === false" class="w-5"></CheckCircleIcon>
|
||||
<span v-if="member.is_placeholder === false">Active</span>
|
||||
<UserCircleIcon
|
||||
v-if="member.is_placeholder === true"
|
||||
class="w-5"></UserCircleIcon>
|
||||
<UserCircleIcon v-if="member.is_placeholder === true" class="w-5"></UserCircleIcon>
|
||||
<span v-if="member.is_placeholder === true">Inactive</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -116,12 +112,8 @@ const userHasValidMailAddress = computed(() => {
|
||||
showMakeMemberPlaceholderModal = true
|
||||
"></MemberMoreOptionsDropdown>
|
||||
</div>
|
||||
<MemberEditModal
|
||||
v-model:show="showEditMemberModal"
|
||||
:member="member"></MemberEditModal>
|
||||
<MemberMergeModal
|
||||
v-model:show="showMergeMemberModal"
|
||||
:member="member"></MemberMergeModal>
|
||||
<MemberEditModal v-model:show="showEditMemberModal" :member="member"></MemberEditModal>
|
||||
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
|
||||
<MemberMakePlaceholderModal
|
||||
v-model:show="showMakeMemberPlaceholderModal"
|
||||
:member="member"></MemberMakePlaceholderModal>
|
||||
|
||||
@@ -41,8 +41,8 @@ defineEmits<{
|
||||
>.
|
||||
</p>
|
||||
<p class="py-0.5 text-center font-semibold">
|
||||
Do you want to update all existing time entries, where the
|
||||
organization billable rate applies as well?
|
||||
Do you want to update all existing time entries, where the organization billable rate
|
||||
applies as well?
|
||||
</p>
|
||||
</BillableRateModal>
|
||||
</template>
|
||||
|
||||
@@ -1,41 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import ProjectBadge from "@/packages/ui/src/Project/ProjectBadge.vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { useProjectsStore } from "@/utils/useProjects";
|
||||
import Dropdown from "@/packages/ui/src/Input/Dropdown.vue";
|
||||
import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxRoot,
|
||||
ComboboxViewport
|
||||
} from "radix-vue";
|
||||
import { PlusCircleIcon } from "@heroicons/vue/20/solid";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { api } from "@/packages/api/src";
|
||||
import { usePage } from "@inertiajs/vue3";
|
||||
import { getRandomColor } from "@/packages/ui/src/utils/color";
|
||||
import type { Project } from "@/packages/api/src";
|
||||
import ProjectDropdownItem from "@/packages/ui/src/Project/ProjectDropdownItem.vue";
|
||||
import { UseFocusTrap } from "@vueuse/integrations/useFocusTrap/component";
|
||||
ComboboxViewport,
|
||||
} from 'radix-vue';
|
||||
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { api } from '@/packages/api/src';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { getRandomColor } from '@/packages/ui/src/utils/color';
|
||||
import type { Project } from '@/packages/api/src';
|
||||
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
|
||||
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
||||
|
||||
const searchValue = ref("");
|
||||
const searchValue = ref('');
|
||||
const searchInput = ref<HTMLElement | null>(null);
|
||||
const model = defineModel<string | null>({
|
||||
default: null
|
||||
default: null,
|
||||
});
|
||||
const open = ref(false);
|
||||
const projectsStore = useProjectsStore();
|
||||
const emit = defineEmits(["update:modelValue", "changed"]);
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
const { projects } = storeToRefs(projectsStore);
|
||||
const projectDropdownTrigger = ref<HTMLElement | null>(null);
|
||||
const shownProjects = computed(() => {
|
||||
return projects.value.filter((project) => {
|
||||
return project.name
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || "");
|
||||
return project.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +42,7 @@ withDefaults(
|
||||
border?: boolean;
|
||||
}>(),
|
||||
{
|
||||
border: true
|
||||
border: true,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -62,13 +60,13 @@ async function addProjectIfNoneExists() {
|
||||
{
|
||||
name: searchValue.value,
|
||||
color: getRandomColor(),
|
||||
is_billable: false
|
||||
is_billable: false,
|
||||
},
|
||||
{ params: { organization: page.props.auth.user.current_team_id } }
|
||||
);
|
||||
projects.value.unshift(response.data);
|
||||
model.value = response.data.id;
|
||||
searchValue.value = "";
|
||||
searchValue.value = '';
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
@@ -95,16 +93,16 @@ function isProjectSelected(project: Project) {
|
||||
}
|
||||
|
||||
const selectedProjectName = computed(() => {
|
||||
return currentProject.value?.name || "No Project";
|
||||
return currentProject.value?.name || 'No Project';
|
||||
});
|
||||
|
||||
const selectedProjectColor = computed(() => {
|
||||
return currentProject.value?.color || "var(--theme-color-icon-default)";
|
||||
return currentProject.value?.color || 'var(--theme-color-icon-default)';
|
||||
});
|
||||
|
||||
function updateValue(project: Project) {
|
||||
model.value = project.id;
|
||||
emit("changed");
|
||||
emit('changed');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -122,16 +120,13 @@ function updateValue(project: Project) {
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<UseFocusTrap
|
||||
v-if="open"
|
||||
:options="{ immediate: true, allowOutsideClick: true }">
|
||||
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
|
||||
<ComboboxRoot
|
||||
v-model:search-term="searchValue"
|
||||
:open="open"
|
||||
:model-value="currentProject"
|
||||
class="relative"
|
||||
@update:model-value="updateValue"
|
||||
>
|
||||
@update:model-value="updateValue">
|
||||
<ComboboxAnchor>
|
||||
<ComboboxInput
|
||||
ref="searchInput"
|
||||
@@ -155,19 +150,12 @@ function updateValue(project: Project) {
|
||||
:name="project.name"></ProjectDropdownItem>
|
||||
</ComboboxItem>
|
||||
<div
|
||||
v-if="
|
||||
searchValue.length > 0 &&
|
||||
shownProjects.length === 0
|
||||
"
|
||||
v-if="searchValue.length > 0 && shownProjects.length === 0"
|
||||
class="bg-card-background-active">
|
||||
<div
|
||||
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-separator">
|
||||
<PlusCircleIcon
|
||||
class="w-5 flex-shrink-0"></PlusCircleIcon>
|
||||
<span
|
||||
>Add "{{ searchValue }}" as a new
|
||||
Project</span
|
||||
>
|
||||
<PlusCircleIcon class="w-5 flex-shrink-0"></PlusCircleIcon>
|
||||
<span>Add "{{ searchValue }}" as a new Project</span>
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxViewport>
|
||||
|
||||
@@ -3,11 +3,7 @@ import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
Project,
|
||||
} from '@/packages/api/src';
|
||||
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
@@ -64,9 +60,7 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
|
||||
const currentClientName = computed(() => {
|
||||
if (project.value.client_id) {
|
||||
return clients.value.find(
|
||||
(client) => client.id === project.value.client_id
|
||||
)?.name;
|
||||
return clients.value.find((client) => client.id === project.value.client_id)?.name;
|
||||
}
|
||||
return 'No Client';
|
||||
});
|
||||
@@ -87,8 +81,7 @@ async function submitBillableRate() {
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div
|
||||
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-5">
|
||||
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-5">
|
||||
<div class="flex-1 flex items-center">
|
||||
<div class="text-center">
|
||||
<InputLabel for="color" value="Color" />
|
||||
@@ -122,8 +115,7 @@ async function submitBillableRate() {
|
||||
class="bg-input-background cursor-pointer hover:bg-tertiary"
|
||||
size="xlarge">
|
||||
<div class="flex items-center space-x-2">
|
||||
<UserCircleIcon
|
||||
class="w-5 text-icon-default"></UserCircleIcon>
|
||||
<UserCircleIcon class="w-5 text-icon-default"></UserCircleIcon>
|
||||
<span class="whitespace-nowrap">
|
||||
{{ currentClientName }}
|
||||
</span>
|
||||
@@ -137,9 +129,7 @@ async function submitBillableRate() {
|
||||
<div>
|
||||
<ProjectEditBillableSection
|
||||
v-model:is-billable="project.is_billable"
|
||||
v-model:billable-rate="
|
||||
project.billable_rate
|
||||
"
|
||||
v-model:billable-rate="project.billable_rate"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
@submit="submit"></ProjectEditBillableSection>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TrashIcon,
|
||||
PencilSquareIcon,
|
||||
ArchiveBoxIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import { TrashIcon, PencilSquareIcon, ArchiveBoxIcon } from '@heroicons/vue/20/solid';
|
||||
import type { Project } from '@/packages/api/src';
|
||||
import { canDeleteProjects, canUpdateProjects } from '@/utils/permissions';
|
||||
import {
|
||||
|
||||
@@ -7,12 +7,7 @@ import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue
|
||||
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import type {
|
||||
CreateProjectBody,
|
||||
Project,
|
||||
Client,
|
||||
CreateClientBody,
|
||||
} from '@/packages/api/src';
|
||||
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { storeToRefs } from 'pinia';
|
||||
@@ -24,15 +19,11 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const showCreateProjectModal = ref(false);
|
||||
async function createProject(
|
||||
project: CreateProjectBody
|
||||
): Promise<Project | undefined> {
|
||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||
return await useProjectsStore().createProject(project);
|
||||
}
|
||||
|
||||
async function createClient(
|
||||
client: CreateClientBody
|
||||
): Promise<Client | undefined> {
|
||||
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
|
||||
return await useClientsStore().createClient(client);
|
||||
}
|
||||
const { clients } = storeToRefs(useClientsStore());
|
||||
@@ -52,19 +43,11 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction"></ProjectCreateModal>
|
||||
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div
|
||||
data-testid="project_table"
|
||||
class="grid min-w-full"
|
||||
:style="gridTemplate">
|
||||
<div data-testid="project_table" class="grid min-w-full" :style="gridTemplate">
|
||||
<ProjectTableHeading
|
||||
:show-billable-rate="
|
||||
props.showBillableRate
|
||||
"></ProjectTableHeading>
|
||||
<div
|
||||
v-if="projects.length === 0"
|
||||
class="col-span-5 py-24 text-center">
|
||||
<FolderPlusIcon
|
||||
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||
:show-billable-rate="props.showBillableRate"></ProjectTableHeading>
|
||||
<div v-if="projects.length === 0" class="col-span-5 py-24 text-center">
|
||||
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||
<h3 class="text-text-primary font-semibold">
|
||||
{{
|
||||
canCreateProjects()
|
||||
|
||||
@@ -12,15 +12,9 @@ defineProps<{
|
||||
Name
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Client</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
||||
Total Time
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
||||
Progress
|
||||
</div>
|
||||
<div
|
||||
v-if="showBillableRate"
|
||||
class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
|
||||
<div v-if="showBillableRate" class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
||||
Billable Rate
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
|
||||
|
||||
@@ -26,14 +26,11 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const client = computed(() => {
|
||||
return clients.value.find(
|
||||
(client) => client.id === props.project.client_id
|
||||
);
|
||||
return clients.value.find((client) => client.id === props.project.client_id);
|
||||
});
|
||||
|
||||
const projectTasksCount = computed(() => {
|
||||
return tasks.value.filter((task) => task.project_id === props.project.id)
|
||||
.length;
|
||||
return tasks.value.filter((task) => task.project_id === props.project.id).length;
|
||||
});
|
||||
|
||||
function deleteProject() {
|
||||
@@ -67,7 +64,6 @@ const billableRateInfo = computed(() => {
|
||||
});
|
||||
|
||||
const showEditProjectModal = ref(false);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -86,15 +82,10 @@ 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
|
||||
v-if="project.client_id"
|
||||
class="overflow-ellipsis overflow-hidden">
|
||||
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
|
||||
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
||||
{{ client?.name }}
|
||||
</div>
|
||||
<div v-else>No client</div>
|
||||
@@ -111,10 +102,8 @@ const showEditProjectModal = ref(false);
|
||||
</div>
|
||||
<div v-else>--</div>
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
||||
<UpgradeBadge
|
||||
v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
||||
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
||||
<EstimatedTimeProgress
|
||||
v-else-if="project.estimated_time"
|
||||
:estimated="project.estimated_time"
|
||||
|
||||
@@ -42,8 +42,8 @@ defineEmits<{
|
||||
>.
|
||||
</p>
|
||||
<p class="py-1 text-center font-semibold max-w-md mx-auto">
|
||||
Do you want to update all existing time entries, where the project
|
||||
member billable rate applies as well?
|
||||
Do you want to update all existing time entries, where the project member billable rate
|
||||
applies as well?
|
||||
</p>
|
||||
</BillableRateModal>
|
||||
</template>
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { ref } from 'vue';
|
||||
import type {
|
||||
CreateProjectMemberBody,
|
||||
ProjectMember,
|
||||
} from '@/packages/api/src';
|
||||
import type { CreateProjectMemberBody, ProjectMember } from '@/packages/api/src';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { useProjectMembersStore } from '@/utils/useProjectMembers';
|
||||
@@ -57,9 +54,7 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
</div>
|
||||
<div class="col-span-3 sm:col-span-1 flex-1">
|
||||
<BillableRateInput
|
||||
v-model="
|
||||
projectMember.billable_rate
|
||||
"
|
||||
v-model="projectMember.billable_rate"
|
||||
name="billable_rate"
|
||||
:currency="getOrganizationCurrencyString()"></BillableRateInput>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import type {
|
||||
ProjectMember,
|
||||
UpdateProjectMemberBody,
|
||||
} from '@/packages/api/src';
|
||||
import type { ProjectMember, UpdateProjectMemberBody } from '@/packages/api/src';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { useProjectMembersStore } from '@/utils/useProjectMembers';
|
||||
@@ -29,10 +26,7 @@ const projectMemberBody = ref<UpdateProjectMemberBody>({
|
||||
});
|
||||
const showBillableRateModal = ref(false);
|
||||
async function submit() {
|
||||
if (
|
||||
props.projectMember.billable_rate !==
|
||||
projectMemberBody.value.billable_rate
|
||||
) {
|
||||
if (props.projectMember.billable_rate !== projectMemberBody.value.billable_rate) {
|
||||
// make sure that the alert modal is not immediately submitted when user presses enter
|
||||
setTimeout(() => {
|
||||
showBillableRateModal.value = true;
|
||||
@@ -84,20 +78,14 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
@close="showBillableRateModal = false"
|
||||
@submit="submitBillableRate"></ProjectMemberBillableRateModal>
|
||||
<div class="grid grid-cols-3 items-center space-x-4">
|
||||
<div
|
||||
class="col-span-3 sm:col-span-2 space-x-2 flex items-center">
|
||||
<div class="col-span-3 sm:col-span-2 space-x-2 flex items-center">
|
||||
<UserIcon class="w-4 text-text-secondary"></UserIcon>
|
||||
<span>{{ props.name }}</span>
|
||||
</div>
|
||||
<div class="col-span-3 sm:col-span-1 flex-1">
|
||||
<InputLabel
|
||||
for="billable_rate"
|
||||
class="mb-2"
|
||||
value="Billable Rate"></InputLabel>
|
||||
<InputLabel for="billable_rate" class="mb-2" value="Billable Rate"></InputLabel>
|
||||
<BillableRateInput
|
||||
v-model="
|
||||
projectMemberBody.billable_rate
|
||||
"
|
||||
v-model="projectMemberBody.billable_rate"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
name="billable_rate"
|
||||
@keydown.enter="submit"></BillableRateInput>
|
||||
|
||||
@@ -22,9 +22,7 @@ const props = defineProps<{
|
||||
const { members } = storeToRefs(useMembersStore());
|
||||
|
||||
const currentMember = computed(() => {
|
||||
return members.value.find(
|
||||
(member) => member.id === props.projectMember.user_id
|
||||
);
|
||||
return members.value.find((member) => member.id === props.projectMember.user_id);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -28,24 +28,16 @@ const createProjectMember = ref(false);
|
||||
class="grid min-w-full"
|
||||
style="grid-template-columns: 1fr 150px 150px 80px">
|
||||
<ProjectMemberTableHeading></ProjectMemberTableHeading>
|
||||
<div
|
||||
v-if="projectMembers.length === 0"
|
||||
class="col-span-5 py-24 text-center">
|
||||
<UserGroupIcon
|
||||
class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
|
||||
<div v-if="projectMembers.length === 0" class="col-span-5 py-24 text-center">
|
||||
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
|
||||
<h3 class="text-text-primary font-semibold">No project members</h3>
|
||||
<p class="pb-5">Add the first project member!</p>
|
||||
<SecondaryButton
|
||||
:icon="PlusIcon"
|
||||
@click="createProjectMember = true"
|
||||
<SecondaryButton :icon="PlusIcon" @click="createProjectMember = true"
|
||||
>Add a new Project Member
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<template
|
||||
v-for="projectMember in projectMembers"
|
||||
:key="projectMember.id">
|
||||
<ProjectMemberTableRow
|
||||
:project-member="projectMember"></ProjectMemberTableRow>
|
||||
<template v-for="projectMember in projectMembers" :key="projectMember.id">
|
||||
<ProjectMemberTableRow :project-member="projectMember"></ProjectMemberTableRow>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,7 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Name
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
||||
Billable Rate
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<span class="sr-only">Edit</span>
|
||||
|
||||
@@ -31,9 +31,7 @@ function editProjectMember() {
|
||||
|
||||
const { members } = storeToRefs(useMembersStore());
|
||||
const member = computed(() => {
|
||||
return members.value.find(
|
||||
(member) => member.id === props.projectMember.member_id
|
||||
);
|
||||
return members.value.find((member) => member.id === props.projectMember.member_id);
|
||||
});
|
||||
const showEditModal = ref(false);
|
||||
</script>
|
||||
|
||||
@@ -5,10 +5,7 @@ import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { ref } from 'vue';
|
||||
import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import InputLabel from '../../../packages/ui/src/Input/InputLabel.vue';
|
||||
import type {
|
||||
CreateReportBody,
|
||||
CreateReportBodyProperties,
|
||||
} from '@/packages/api/src';
|
||||
import type { CreateReportBody, CreateReportBodyProperties } from '@/packages/api/src';
|
||||
import { useMutation } from '@tanstack/vue-query';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { api } from '@/packages/api/src';
|
||||
@@ -80,10 +77,7 @@ async function submit() {
|
||||
<div class="items-center space-y-4 w-full">
|
||||
<div class="w-full">
|
||||
<InputLabel for="name" value="Name" />
|
||||
<TextInput
|
||||
id="name"
|
||||
v-model="report.name"
|
||||
class="mt-1.5 w-full"></TextInput>
|
||||
<TextInput id="name" v-model="report.name" class="mt-1.5 w-full"></TextInput>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="description" value="Description" />
|
||||
@@ -95,19 +89,13 @@ async function submit() {
|
||||
<InputLabel value="Visibility" />
|
||||
<div class="flex items-center space-x-12">
|
||||
<div class="flex items-center space-x-3 px-2 py-3">
|
||||
<Checkbox
|
||||
id="is_public"
|
||||
v-model:checked="report.is_public"></Checkbox>
|
||||
<Checkbox id="is_public" v-model:checked="report.is_public"></Checkbox>
|
||||
<InputLabel for="is_public" value="Public" />
|
||||
</div>
|
||||
<div
|
||||
v-if="report.is_public"
|
||||
class="flex items-center space-x-4">
|
||||
<div v-if="report.is_public" class="flex items-center space-x-4">
|
||||
<div>
|
||||
<InputLabel for="public_until" value="Expires at" />
|
||||
<div class="text-text-tertiary font-medium">
|
||||
(optional)
|
||||
</div>
|
||||
<div class="text-text-tertiary font-medium">(optional)</div>
|
||||
</div>
|
||||
<DatePicker id="public_until"></DatePicker>
|
||||
</div>
|
||||
|
||||
@@ -94,10 +94,7 @@ async function submit() {
|
||||
<div class="items-center space-y-4 w-full">
|
||||
<div class="w-full">
|
||||
<InputLabel for="name" value="Name" />
|
||||
<TextInput
|
||||
id="name"
|
||||
v-model="report.name"
|
||||
class="mt-1.5 w-full"></TextInput>
|
||||
<TextInput id="name" v-model="report.name" class="mt-1.5 w-full"></TextInput>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="description" value="Description" />
|
||||
@@ -109,14 +106,10 @@ async function submit() {
|
||||
<InputLabel value="Visibility" />
|
||||
<div class="flex items-center space-x-12">
|
||||
<div class="flex items-center space-x-2 px-2 py-3">
|
||||
<Checkbox
|
||||
id="is_public"
|
||||
v-model:checked="report.is_public"></Checkbox>
|
||||
<Checkbox id="is_public" v-model:checked="report.is_public"></Checkbox>
|
||||
<InputLabel for="is_public" value="Public" />
|
||||
</div>
|
||||
<div
|
||||
v-if="report.is_public"
|
||||
class="flex items-center space-x-4">
|
||||
<div v-if="report.is_public" class="flex items-center space-x-4">
|
||||
<InputLabel for="public_until" value="Expires at" />
|
||||
<DatePicker id="public_until"></DatePicker>
|
||||
</div>
|
||||
|
||||
@@ -31,13 +31,9 @@ function onSaveReportClick() {
|
||||
v-model:show="showCreateReportModal"
|
||||
:properties="reportProperties"></ReportCreateModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal">
|
||||
<strong>Sharable Reports</strong> is only available in solidtime
|
||||
Professional.
|
||||
<strong>Sharable Reports</strong> is only available in solidtime Professional.
|
||||
</UpgradeModal>
|
||||
<SecondaryButton
|
||||
v-if="canCreateReports()"
|
||||
:icon="SaveIcon"
|
||||
@click="onSaveReportClick"
|
||||
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick"
|
||||
>Save Report</SecondaryButton
|
||||
>
|
||||
</template>
|
||||
|
||||
@@ -21,25 +21,15 @@ const gridTemplate = computed(() => {
|
||||
<template>
|
||||
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div
|
||||
data-testid="report_table"
|
||||
class="grid min-w-full"
|
||||
:style="gridTemplate">
|
||||
<div data-testid="report_table" class="grid min-w-full" :style="gridTemplate">
|
||||
<ReportTableHeading></ReportTableHeading>
|
||||
<div
|
||||
v-if="reports.length === 0"
|
||||
class="col-span-5 py-24 text-center">
|
||||
<FolderPlusIcon
|
||||
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||
<h3 class="text-text-primary font-semibold">
|
||||
No shared reports found
|
||||
</h3>
|
||||
<div v-if="reports.length === 0" class="col-span-5 py-24 text-center">
|
||||
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||
<h3 class="text-text-primary font-semibold">No shared reports found</h3>
|
||||
<p v-if="canCreateProjects()" class="pb-5">
|
||||
Go to the overview to create a report
|
||||
</p>
|
||||
<SecondaryButton
|
||||
:icon="PlusIcon"
|
||||
@click="router.visit(route('reporting'))"
|
||||
<SecondaryButton :icon="PlusIcon" @click="router.visit(route('reporting'))"
|
||||
>Go to overview
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
|
||||
@@ -8,15 +8,9 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Name
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
||||
Description
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
||||
Visibility
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
||||
Public URL
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Description</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Visibility</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Public URL</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
|
||||
@@ -57,9 +57,7 @@ async function deleteReport() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportEditModal
|
||||
v-model:show="showEditReportModal"
|
||||
:original-report="report"></ReportEditModal>
|
||||
<ReportEditModal v-model:show="showEditReportModal" :original-report="report"></ReportEditModal>
|
||||
<TableRow>
|
||||
<div
|
||||
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
@@ -75,14 +73,9 @@ async function deleteReport() {
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
||||
{{ report.is_public ? 'Public' : 'Private' }}
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
||||
<div
|
||||
v-if="report.shareable_link"
|
||||
class="space-x-2 flex items-center">
|
||||
<SecondaryButton
|
||||
v-if="isSupported"
|
||||
@click="copy(report.shareable_link)">
|
||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
||||
<div v-if="report.shareable_link" class="space-x-2 flex items-center">
|
||||
<SecondaryButton v-if="isSupported" @click="copy(report.shareable_link)">
|
||||
<span v-if="!copied">Copy URL</span>
|
||||
<span v-else>Copied!</span>
|
||||
</SecondaryButton>
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
|
||||
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
||||
import {
|
||||
formatDate,
|
||||
formatHumanReadableDuration,
|
||||
formatWeek,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { formatDate, formatHumanReadableDuration, formatWeek } from '@/packages/ui/src/utils/time';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { BarChart } from 'echarts/charts';
|
||||
@@ -19,14 +15,7 @@ import {
|
||||
import type { AggregatedTimeEntries, Organization } from '@/packages/api/src';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
TitleComponent,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
]);
|
||||
use([CanvasRenderer, BarChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);
|
||||
|
||||
provide(THEME_KEY, 'dark');
|
||||
|
||||
@@ -127,7 +116,7 @@ const option = computed(() => ({
|
||||
fontWeight: 600,
|
||||
color: labelColor.value,
|
||||
margin: 16,
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
},
|
||||
axisTick: {
|
||||
lineStyle: {
|
||||
@@ -139,7 +128,7 @@ const option = computed(() => ({
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: labelColor.value,
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
@@ -174,9 +163,7 @@ const option = computed(() => ({
|
||||
class="chart"
|
||||
:option="option" />
|
||||
<div v-else class="chart flex flex-col items-center justify-center">
|
||||
<p class="text-lg text-text-primary font-semibold">
|
||||
No time entries found
|
||||
</p>
|
||||
<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>
|
||||
|
||||
@@ -27,15 +27,11 @@ function triggerDownload(format: ExportFormat) {
|
||||
<template>
|
||||
<Dropdown align="end">
|
||||
<template #trigger>
|
||||
<SecondaryButton :icon="ArrowDownTrayIcon" :loading>
|
||||
Export
|
||||
</SecondaryButton>
|
||||
<SecondaryButton :icon="ArrowDownTrayIcon" :loading> Export </SecondaryButton>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex flex-col space-y-1 p-1.5">
|
||||
<SecondaryButton
|
||||
class="border-0 px-2"
|
||||
@click="triggerDownload('pdf')">
|
||||
<SecondaryButton class="border-0 px-2" @click="triggerDownload('pdf')">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span> Export as PDF </span>
|
||||
<LockClosedIcon
|
||||
@@ -43,27 +39,20 @@ function triggerDownload(format: ExportFormat) {
|
||||
class="w-3.5 text-text-tertiary"></LockClosedIcon>
|
||||
</div>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
class="border-0 px-2"
|
||||
@click="triggerDownload('xlsx')"
|
||||
<SecondaryButton class="border-0 px-2" @click="triggerDownload('xlsx')"
|
||||
>Export as Excel</SecondaryButton
|
||||
>
|
||||
<SecondaryButton
|
||||
class="border-0 px-2"
|
||||
@click="triggerDownload('csv')"
|
||||
<SecondaryButton class="border-0 px-2" @click="triggerDownload('csv')"
|
||||
>Export as CSV</SecondaryButton
|
||||
>
|
||||
<SecondaryButton
|
||||
class="border-0 px-2"
|
||||
@click="triggerDownload('ods')"
|
||||
<SecondaryButton class="border-0 px-2" @click="triggerDownload('ods')"
|
||||
>Export as ODS
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<UpgradeModal v-model:show="showPremiumModal">
|
||||
<strong>PDF Reports</strong> are only available in solidtime
|
||||
Professional.
|
||||
<strong>PDF Reports</strong> are only available in solidtime Professional.
|
||||
</UpgradeModal>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
CheckCircleIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import { ArrowDownTrayIcon, CheckCircleIcon, XMarkIcon } from '@heroicons/vue/20/solid';
|
||||
import { Modal, PrimaryButton } from '@/packages/ui/src';
|
||||
const props = defineProps<{
|
||||
exportUrl: string | null;
|
||||
@@ -19,30 +15,19 @@ function downloadCurrentExport() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
closeable
|
||||
max-width="lg"
|
||||
:show="showExportModal"
|
||||
@close="showExportModal = false">
|
||||
<Modal closeable max-width="lg" :show="showExportModal" @close="showExportModal = false">
|
||||
<button
|
||||
class="text-text-tertiary w-6 mx-auto absolute focus-visible:outline-none focus-visible:ring-2 rounded-full focus-visible:ring-ring transition focus-visible:text-text-primary hover:text-text-primary top-2 right-2">
|
||||
<XMarkIcon @click="showExportModal = false"></XMarkIcon>
|
||||
</button>
|
||||
<div class="text-center text-text-primary py-6">
|
||||
<div
|
||||
class="flex items-center font-semibold text-lg justify-center space-x-2 pb-2">
|
||||
<CheckCircleIcon
|
||||
class="text-text-tertiary w-6"></CheckCircleIcon>
|
||||
<div class="flex items-center font-semibold text-lg justify-center space-x-2 pb-2">
|
||||
<CheckCircleIcon class="text-text-tertiary w-6"></CheckCircleIcon>
|
||||
<span> Export Successful! </span>
|
||||
</div>
|
||||
<div class="text-center text-sm max-w-64 mx-auto">
|
||||
<p class="pb-5">
|
||||
Your export is ready, you can download it with the button
|
||||
below.
|
||||
</p>
|
||||
<PrimaryButton
|
||||
:icon="ArrowDownTrayIcon"
|
||||
@click="downloadCurrentExport"
|
||||
<p class="pb-5">Your export is ready, you can download it with the button below.</p>
|
||||
<PrimaryButton :icon="ArrowDownTrayIcon" @click="downloadCurrentExport"
|
||||
>Download</PrimaryButton
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/packages/ui/src/Badge.vue';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
|
||||
const props = defineProps<{
|
||||
icon: Component;
|
||||
@@ -12,32 +12,29 @@ import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const activeClass = computed(() => {
|
||||
if (props.active) {
|
||||
return 'border-accent-300/50 bg-accent-300/10 hover:bg-accent-300/20';
|
||||
return 'border-accent-300/50 bg-accent-50 hover:bg-accent-100 dark:border-accent-300/50 dark:bg-accent-300/5 dark:hover:bg-accent-300/10';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const iconClass = computed(() => {
|
||||
return twMerge(
|
||||
'-ml-0.5 h-4 w-4',
|
||||
props.active ? 'dark:text-accent-300/80 text-accent-400/80' : 'text-text-quaternary'
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge
|
||||
size="large"
|
||||
tag="button"
|
||||
:class="
|
||||
twMerge(
|
||||
'cursor-pointer bg-input-background hover:bg-card-background transition flex',
|
||||
activeClass
|
||||
)
|
||||
">
|
||||
<component
|
||||
:is="icon"
|
||||
class="-ml-0.5 h-4 w-4 text-text-quaternary"></component>
|
||||
<Button variant="outline" size="sm" :class="twMerge(activeClass)">
|
||||
<component :is="icon" :class="iconClass"></component>
|
||||
<span class="text-nowrap"> {{ title }} </span>
|
||||
<div
|
||||
v-if="count"
|
||||
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
||||
{{ count }}
|
||||
</div>
|
||||
</Badge>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -8,12 +8,10 @@ const props = defineProps<{
|
||||
groupByOptions: { value: string; label: string; icon: Component }[];
|
||||
}>();
|
||||
const icon = computed(() => {
|
||||
return props.groupByOptions.find((option) => option.value === model.value)
|
||||
?.icon;
|
||||
return props.groupByOptions.find((option) => option.value === model.value)?.icon;
|
||||
});
|
||||
const title = computed(() => {
|
||||
return props.groupByOptions.find((option) => option.value === model.value)
|
||||
?.label;
|
||||
return props.groupByOptions.find((option) => option.value === model.value)?.label;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChartBarIcon,
|
||||
CheckCircleIcon,
|
||||
TagIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
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';
|
||||
@@ -16,6 +11,7 @@ import {
|
||||
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 ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.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';
|
||||
@@ -33,7 +29,7 @@ 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 { computed, type ComputedRef, inject, onMounted, ref, watch } from 'vue';
|
||||
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import {
|
||||
@@ -42,11 +38,7 @@ import {
|
||||
type CreateReportBodyProperties,
|
||||
type Organization,
|
||||
} from '@/packages/api/src';
|
||||
import {
|
||||
getCurrentMembershipId,
|
||||
getCurrentOrganizationId,
|
||||
getCurrentRole,
|
||||
} from '@/utils/useUser';
|
||||
import { getCurrentMembershipId, getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { useSessionStorage, useStorage } from '@vueuse/core';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
@@ -54,6 +46,9 @@ import type { ExportFormat } from '@/types/reporting';
|
||||
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
|
||||
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
|
||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
|
||||
const startDate = useSessionStorage<string>(
|
||||
@@ -71,19 +66,26 @@ const selectedTasks = ref<string[]>([]);
|
||||
const selectedClients = ref<string[]>([]);
|
||||
|
||||
const billable = ref<'true' | 'false' | null>(null);
|
||||
const roundingEnabled = ref<boolean>(false);
|
||||
const roundingType = ref<TimeEntryRoundingType>('nearest');
|
||||
const roundingMinutes = ref<number>(15);
|
||||
|
||||
const group = useStorage<GroupingOption>('reporting-group', 'project');
|
||||
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
|
||||
|
||||
const reportingStore = useReportingStore();
|
||||
|
||||
const { aggregatedGraphTimeEntries, aggregatedTableTimeEntries } =
|
||||
storeToRefs(reportingStore);
|
||||
const { aggregatedGraphTimeEntries, aggregatedTableTimeEntries } = storeToRefs(reportingStore);
|
||||
|
||||
const { groupByOptions } = reportingStore;
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
// Watch rounding enabled state to trigger updates
|
||||
watch(roundingEnabled, () => {
|
||||
updateReporting();
|
||||
});
|
||||
|
||||
function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
|
||||
let params: AggregatedTimeEntriesQueryParams = {
|
||||
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
|
||||
@@ -91,26 +93,15 @@ function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
|
||||
};
|
||||
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,
|
||||
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,
|
||||
member_id:
|
||||
getCurrentRole() === 'employee'
|
||||
? getCurrentMembershipId()
|
||||
: undefined,
|
||||
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
|
||||
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
||||
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
|
||||
};
|
||||
return params;
|
||||
}
|
||||
@@ -128,9 +119,7 @@ function updateGraphReporting() {
|
||||
function updateTableReporting() {
|
||||
const params = getFilterAttributes();
|
||||
if (group.value === subGroup.value) {
|
||||
const fallbackOption = groupByOptions.find(
|
||||
(el) => el.value !== group.value
|
||||
);
|
||||
const fallbackOption = groupByOptions.find((el) => el.value !== group.value);
|
||||
if (fallbackOption?.value) {
|
||||
subGroup.value = fallbackOption.value;
|
||||
}
|
||||
@@ -148,14 +137,8 @@ function updateReporting() {
|
||||
updateTableReporting();
|
||||
}
|
||||
|
||||
function getOptimalGroupingOption(
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): 'day' | 'week' | 'month' {
|
||||
const diffInDays = getDayJsInstance()(endDate).diff(
|
||||
getDayJsInstance()(startDate),
|
||||
'd'
|
||||
);
|
||||
function getOptimalGroupingOption(startDate: string, endDate: string): 'day' | 'week' | 'month' {
|
||||
const diffInDays = getDayJsInstance()(endDate).diff(getDayJsInstance()(startDate), 'd');
|
||||
|
||||
if (diffInDays <= 31) {
|
||||
return 'day';
|
||||
@@ -199,10 +182,7 @@ async function downloadExport(format: ExportFormat) {
|
||||
...getFilterAttributes(),
|
||||
group: group.value,
|
||||
sub_group: subGroup.value,
|
||||
history_group: getOptimalGroupingOption(
|
||||
startDate.value,
|
||||
endDate.value
|
||||
),
|
||||
history_group: getOptimalGroupingOption(startDate.value, endDate.value),
|
||||
format: format,
|
||||
},
|
||||
}),
|
||||
@@ -235,17 +215,12 @@ const groupedPieChartData = computed(() => {
|
||||
if (
|
||||
name &&
|
||||
aggregatedTableTimeEntries.value?.grouped_type &&
|
||||
emptyPlaceholder[
|
||||
aggregatedTableTimeEntries.value?.grouped_type
|
||||
] === name
|
||||
emptyPlaceholder[aggregatedTableTimeEntries.value?.grouped_type] === name
|
||||
) {
|
||||
color = '#CCCCCC';
|
||||
} else if (
|
||||
aggregatedTableTimeEntries.value?.grouped_type === 'project'
|
||||
) {
|
||||
} else if (aggregatedTableTimeEntries.value?.grouped_type === 'project') {
|
||||
color =
|
||||
projects.value?.find((project) => project.id === entry.key)
|
||||
?.color ?? '#CCCCCC';
|
||||
projects.value?.find((project) => project.id === entry.key)?.color ?? '#CCCCCC';
|
||||
}
|
||||
return {
|
||||
value: entry.seconds,
|
||||
@@ -274,10 +249,7 @@ const tableData = computed(() => {
|
||||
return {
|
||||
seconds: el.seconds,
|
||||
cost: el.cost,
|
||||
description: getNameForReportingRowEntry(
|
||||
el.key,
|
||||
entry.grouped_type
|
||||
),
|
||||
description: getNameForReportingRowEntry(el.key, entry.grouped_type),
|
||||
};
|
||||
}) ?? [],
|
||||
};
|
||||
@@ -296,20 +268,15 @@ const tableData = computed(() => {
|
||||
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<ReportingExportButton
|
||||
:download="downloadExport"></ReportingExportButton>
|
||||
<ReportSaveButton
|
||||
:report-properties="reportProperties"></ReportSaveButton>
|
||||
<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="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3">
|
||||
<div class="text-sm font-medium">Filters</div>
|
||||
<MemberMultiselectDropdown
|
||||
v-model="selectedMembers"
|
||||
@submit="updateReporting">
|
||||
<MemberMultiselectDropdown v-model="selectedMembers" @submit="updateReporting">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
:count="selectedMembers.length"
|
||||
@@ -318,9 +285,7 @@ const tableData = computed(() => {
|
||||
:icon="UserGroupIcon"></ReportingFilterBadge>
|
||||
</template>
|
||||
</MemberMultiselectDropdown>
|
||||
<ProjectMultiselectDropdown
|
||||
v-model="selectedProjects"
|
||||
@submit="updateReporting">
|
||||
<ProjectMultiselectDropdown v-model="selectedProjects" @submit="updateReporting">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
:count="selectedProjects.length"
|
||||
@@ -329,9 +294,7 @@ const tableData = computed(() => {
|
||||
:icon="FolderIcon"></ReportingFilterBadge>
|
||||
</template>
|
||||
</ProjectMultiselectDropdown>
|
||||
<TaskMultiselectDropdown
|
||||
v-model="selectedTasks"
|
||||
@submit="updateReporting">
|
||||
<TaskMultiselectDropdown v-model="selectedTasks" @submit="updateReporting">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
:count="selectedTasks.length"
|
||||
@@ -340,9 +303,7 @@ const tableData = computed(() => {
|
||||
:icon="CheckCircleIcon"></ReportingFilterBadge>
|
||||
</template>
|
||||
</TaskMultiselectDropdown>
|
||||
<ClientMultiselectDropdown
|
||||
v-model="selectedClients"
|
||||
@submit="updateReporting">
|
||||
<ClientMultiselectDropdown v-model="selectedClients" @submit="updateReporting">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
:count="selectedClients.length"
|
||||
@@ -387,14 +348,15 @@ const tableData = computed(() => {
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
:active="billable !== null"
|
||||
:title="
|
||||
billable === 'false'
|
||||
? 'Non Billable'
|
||||
: 'Billable'
|
||||
"
|
||||
:title="billable === 'false' ? 'Non Billable' : 'Billable'"
|
||||
:icon="BillableIcon"></ReportingFilterBadge>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
<ReportingRoundingControls
|
||||
v-model:enabled="roundingEnabled"
|
||||
v-model:type="roundingType"
|
||||
v-model:minutes="roundingMinutes"
|
||||
@change="updateReporting"></ReportingRoundingControls>
|
||||
</div>
|
||||
<div>
|
||||
<DateRangePicker
|
||||
@@ -408,37 +370,26 @@ const tableData = computed(() => {
|
||||
<div class="pt-10 w-full px-3 relative">
|
||||
<ReportingChart
|
||||
:grouped-type="aggregatedGraphTimeEntries?.grouped_type"
|
||||
:grouped-data="
|
||||
aggregatedGraphTimeEntries?.grouped_data
|
||||
"></ReportingChart>
|
||||
: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="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>
|
||||
@changed="updateTableReporting"></ReportingGroupBySelect>
|
||||
<span>and</span>
|
||||
<ReportingGroupBySelect
|
||||
v-model="subGroup"
|
||||
:group-by-options="
|
||||
groupByOptions.filter((el) => el.value !== group)
|
||||
"
|
||||
@changed="
|
||||
updateTableReporting
|
||||
"></ReportingGroupBySelect>
|
||||
: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="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>
|
||||
@@ -456,13 +407,11 @@ const tableData = computed(() => {
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:type="aggregatedTableTimeEntries.grouped_type"
|
||||
:entry="entry"></ReportingRow>
|
||||
<div
|
||||
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
|
||||
<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">
|
||||
<div class="justify-end flex items-center font-medium">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
aggregatedTableTimeEntries.seconds,
|
||||
@@ -471,8 +420,7 @@ const tableData = computed(() => {
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="justify-end pr-6 flex items-center font-medium">
|
||||
<div class="justify-end pr-6 flex items-center font-medium">
|
||||
{{
|
||||
aggregatedTableTimeEntries.cost
|
||||
? formatCents(
|
||||
@@ -490,16 +438,13 @@ const tableData = computed(() => {
|
||||
<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 class="text-lg text-text-primary font-medium">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>
|
||||
<ReportingPieChart :data="groupedPieChartData"></ReportingPieChart>
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
|
||||
@@ -14,14 +14,7 @@ import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
PieChart,
|
||||
TitleComponent,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
]);
|
||||
use([CanvasRenderer, PieChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);
|
||||
|
||||
provide(THEME_KEY, 'dark');
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<script setup lang="ts">
|
||||
import { Switch } from '@/Components/ui/switch';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldInput,
|
||||
NumberFieldContent,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldDecrement,
|
||||
} from '@/Components/ui/number-field';
|
||||
import { ArrowsUpDownIcon } from '@heroicons/vue/20/solid';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { CreditCardIcon } from '@heroicons/vue/20/solid';
|
||||
// TimeEntryRoundingType definition
|
||||
const TimeEntryRoundingType = {
|
||||
Up: 'up' as const,
|
||||
Down: 'down' as const,
|
||||
Nearest: 'nearest' as const,
|
||||
} as const;
|
||||
|
||||
type TimeEntryRoundingType = (typeof TimeEntryRoundingType)[keyof typeof TimeEntryRoundingType];
|
||||
|
||||
interface Props {
|
||||
enabled: boolean;
|
||||
type: TimeEntryRoundingType;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [value: boolean];
|
||||
'update:type': [value: TimeEntryRoundingType];
|
||||
'update:minutes': [value: number];
|
||||
'change': [];
|
||||
}>();
|
||||
|
||||
function updateEnabled(value: boolean) {
|
||||
emit('update:enabled', value);
|
||||
emit('change');
|
||||
}
|
||||
|
||||
function updateType(value: TimeEntryRoundingType) {
|
||||
emit('update:type', value);
|
||||
emit('change');
|
||||
}
|
||||
|
||||
function updateMinutes(value: number) {
|
||||
emit('update:minutes', value);
|
||||
emit('change');
|
||||
}
|
||||
|
||||
// Predefined intervals
|
||||
const predefinedIntervals = [
|
||||
{ value: '5', label: '5 minutes' },
|
||||
{ value: '6', label: '6 minutes' },
|
||||
{ value: '10', label: '10 minutes' },
|
||||
{ value: '15', label: '15 minutes' },
|
||||
{ value: '30', label: '30 minutes' },
|
||||
{ value: '60', label: '1 hour' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
];
|
||||
|
||||
const showCustomInput = ref(false);
|
||||
const customMinutes = ref(props.minutes);
|
||||
const selectedInterval = ref('');
|
||||
|
||||
// Compute the current interval value based on props
|
||||
const currentInterval = computed(() => {
|
||||
const predefined = predefinedIntervals.find(
|
||||
(interval) => interval.value !== 'custom' && parseInt(interval.value) === props.minutes
|
||||
);
|
||||
return predefined ? predefined.value : 'custom';
|
||||
});
|
||||
|
||||
// Initialize selectedInterval
|
||||
const initializeSelectedInterval = () => {
|
||||
selectedInterval.value = currentInterval.value;
|
||||
showCustomInput.value = selectedInterval.value === 'custom';
|
||||
if (showCustomInput.value) {
|
||||
customMinutes.value = props.minutes;
|
||||
}
|
||||
};
|
||||
|
||||
function handleIntervalChange(value: string) {
|
||||
selectedInterval.value = value;
|
||||
if (value === 'custom') {
|
||||
showCustomInput.value = true;
|
||||
// Update minutes to current custom value to ensure "custom" shows as selected
|
||||
updateMinutes(customMinutes.value);
|
||||
} else {
|
||||
showCustomInput.value = false;
|
||||
const minutes = parseInt(value);
|
||||
updateMinutes(minutes);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomMinutesChange(value: string | number) {
|
||||
const numValue = typeof value === 'string' ? parseInt(value) : value;
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
customMinutes.value = numValue;
|
||||
updateMinutes(numValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes in props.minutes
|
||||
watch(
|
||||
() => props.minutes,
|
||||
(newMinutes) => {
|
||||
customMinutes.value = newMinutes;
|
||||
initializeSelectedInterval();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(currentInterval, () => {
|
||||
initializeSelectedInterval();
|
||||
});
|
||||
|
||||
// Active styling similar to ReportingFilterBadge
|
||||
const activeClass = computed(() => {
|
||||
if (props.enabled) {
|
||||
return 'border-accent-300/50 bg-accent-50 hover:bg-accent-100 dark:border-accent-300/50 dark:bg-accent-300/5 dark:hover:bg-accent-300/10';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const iconClass = computed(() => {
|
||||
return twMerge(
|
||||
'w-4 h-4',
|
||||
props.enabled
|
||||
? 'dark:text-accent-300/80 text-accent-400/80'
|
||||
: 'text-muted-foreground opacity-50'
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" :class="twMerge(activeClass)">
|
||||
<ArrowsUpDownIcon :class="iconClass" />
|
||||
Rounding {{ enabled ? 'on' : 'off' }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-72 p-4">
|
||||
<div v-if="!isAllowedToPerformPremiumAction()" class="flex flex-col space-y-2">
|
||||
<span class="font-semibold text-xs">Premium</span>
|
||||
<span class="text-xs text-text-secondary flex-1"
|
||||
>Rounding is a premium feature. Upgrade to unlock this feature.</span
|
||||
>
|
||||
<Link href="/billing">
|
||||
<Button size="sm" variant="input" class="items-center space-x-1">
|
||||
<CreditCardIcon class="w-3.5 h-3.5 text-text-tertiary mr-1" />
|
||||
Go to Billing
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<InputLabel for="enable-rounding" value="Enable Rounding" />
|
||||
<Switch
|
||||
id="enable-rounding"
|
||||
:model-value="enabled"
|
||||
class="data-[state=checked]:bg-accent-500"
|
||||
@update:model-value="updateEnabled" />
|
||||
</div>
|
||||
<div
|
||||
class="mb-3 pb-2 pt-1 text-xs text-muted-foreground border-b border-border-secondary text-text-tertiary">
|
||||
Rounding is applied to each individual time entry, not to the accumulated
|
||||
total.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputLabel for="rounding-type" value="Rounding Type" class="mb-2" />
|
||||
<Select
|
||||
:model-value="type"
|
||||
:disabled="!enabled"
|
||||
@update:model-value="(value) => updateType(value as TimeEntryRoundingType)">
|
||||
<SelectTrigger
|
||||
id="rounding-type"
|
||||
size="small"
|
||||
class="w-full"
|
||||
:disabled="!enabled">
|
||||
<SelectValue placeholder="Select rounding type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="up">Round Up</SelectItem>
|
||||
<SelectItem value="down">Round Down</SelectItem>
|
||||
<SelectItem value="nearest">Round Nearest</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="minutes-interval" value="Minutes Interval" class="mb-2" />
|
||||
<Select
|
||||
:model-value="selectedInterval"
|
||||
:disabled="!enabled"
|
||||
@update:model-value="(value) => handleIntervalChange(value as string)">
|
||||
<SelectTrigger
|
||||
id="minutes-interval"
|
||||
size="small"
|
||||
class="w-full"
|
||||
:disabled="!enabled">
|
||||
<SelectValue placeholder="Select interval" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="interval in predefinedIntervals"
|
||||
:key="interval.value"
|
||||
:value="interval.value">
|
||||
{{ interval.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div v-if="showCustomInput" class="mt-2">
|
||||
<NumberField
|
||||
id="custom-minutes"
|
||||
:model-value="customMinutes"
|
||||
size="small"
|
||||
:min="1"
|
||||
:max="1440"
|
||||
:disabled="!enabled"
|
||||
class="text-sm"
|
||||
@update:model-value="handleCustomMinutesChange">
|
||||
<NumberFieldContent>
|
||||
<NumberFieldDecrement :disabled="!enabled" />
|
||||
<NumberFieldInput
|
||||
placeholder="Enter custom minutes"
|
||||
:disabled="!enabled" />
|
||||
<NumberFieldIncrement :disabled="!enabled" />
|
||||
</NumberFieldContent>
|
||||
</NumberField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -32,10 +32,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
||||
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
|
||||
<div
|
||||
:class="
|
||||
twMerge(
|
||||
'pl-6 font-medium flex items-center space-x-3',
|
||||
props.indent ? 'pl-16' : ''
|
||||
)
|
||||
twMerge('pl-6 font-medium flex items-center space-x-3', props.indent ? 'pl-16' : '')
|
||||
">
|
||||
<GroupedItemsCountButton
|
||||
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
|
||||
@@ -57,13 +54,17 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
||||
}}
|
||||
</div>
|
||||
<div class="justify-end pr-6 flex items-center">
|
||||
{{ entry.cost ? formatCents(
|
||||
entry.cost,
|
||||
props.currency,
|
||||
organization?.currency_format,
|
||||
organization?.currency_symbol,
|
||||
organization?.number_format
|
||||
) : '--' }}
|
||||
{{
|
||||
entry.cost
|
||||
? formatCents(
|
||||
entry.cost,
|
||||
props.currency,
|
||||
organization?.currency_format,
|
||||
organization?.currency_symbol,
|
||||
organization?.number_format
|
||||
)
|
||||
: '--'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
||||
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
||||
import {canViewReport} from "@/utils/permissions";
|
||||
import {computed} from "vue";
|
||||
import { canViewReport } from '@/utils/permissions';
|
||||
import { computed } from 'vue';
|
||||
defineProps<{
|
||||
active: 'reporting' | 'detailed' | 'shared';
|
||||
}>();
|
||||
@@ -12,17 +12,11 @@ const showSharedReports = computed(() => canViewReport());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabBar
|
||||
:model-value="active"
|
||||
>
|
||||
<TabBarItem
|
||||
value="reporting"
|
||||
@click="router.visit(route('reporting'))"
|
||||
<TabBar :model-value="active">
|
||||
<TabBarItem value="reporting" @click="router.visit(route('reporting'))"
|
||||
>Overview</TabBarItem
|
||||
>
|
||||
<TabBarItem
|
||||
value="detailed"
|
||||
@click="router.visit(route('reporting.detailed'))"
|
||||
<TabBarItem value="detailed" @click="router.visit(route('reporting.detailed'))"
|
||||
>Detailed</TabBarItem
|
||||
>
|
||||
<TabBarItem
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user