mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
2 Commits
feature/ad
...
feature/ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2cdbe1e7e | ||
|
|
9158b445f1 |
23
.github/workflows/npm-format-check.yml
vendored
23
.github/workflows/npm-format-check.yml
vendored
@@ -1,23 +0,0 @@
|
||||
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
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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,6 +3,5 @@
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true,
|
||||
"bracketSameLine": true,
|
||||
"quoteProps": "preserve",
|
||||
"printWidth": 100
|
||||
"quoteProps": "preserve"
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?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,9 +73,7 @@ class ReportController extends Controller
|
||||
false,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
true,
|
||||
$report->properties->roundingType,
|
||||
$report->properties->roundingMinutes,
|
||||
true
|
||||
);
|
||||
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||
$timeEntriesQuery->clone(),
|
||||
@@ -86,9 +84,7 @@ class ReportController extends Controller
|
||||
true,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
true,
|
||||
$report->properties->roundingType,
|
||||
$report->properties->roundingMinutes,
|
||||
true
|
||||
);
|
||||
|
||||
return new DetailedWithDataReportResource($report, $data, $historyData);
|
||||
|
||||
@@ -107,8 +107,6 @@ 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,7 +33,6 @@ 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;
|
||||
@@ -48,7 +47,6 @@ 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;
|
||||
@@ -86,8 +84,7 @@ class TimeEntryController extends Controller
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
}
|
||||
|
||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
||||
|
||||
$totalCount = $timeEntriesQuery->count();
|
||||
|
||||
@@ -141,19 +138,10 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* @return Builder<TimeEntry>
|
||||
*/
|
||||
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member, bool $canAccessPremiumFeatures): Builder
|
||||
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): 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);
|
||||
@@ -187,19 +175,16 @@ 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 && ! $canAccessPremiumFeatures) {
|
||||
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
||||
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, $canAccessPremiumFeatures);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
||||
$timeEntriesQuery->with([
|
||||
'task',
|
||||
'client',
|
||||
@@ -222,9 +207,8 @@ class TimeEntryController extends Controller
|
||||
if ($viewFile === false) {
|
||||
throw new \LogicException('View file not found');
|
||||
}
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery,
|
||||
$timeEntriesQuery->clone()->reorder()->withOnly([]),
|
||||
null,
|
||||
null,
|
||||
$user->timezone,
|
||||
@@ -232,9 +216,7 @@ class TimeEntryController extends Controller
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes,
|
||||
$showBillableRate
|
||||
);
|
||||
$html = Blade::render($viewFile, [
|
||||
'timeEntries' => $timeEntriesQuery->get(),
|
||||
@@ -336,15 +318,12 @@ 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,
|
||||
@@ -355,9 +334,7 @@ class TimeEntryController extends Controller
|
||||
$request->getFillGapsInTimeGroups(),
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
$showBillableRate
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -385,7 +362,6 @@ 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;
|
||||
@@ -397,8 +373,6 @@ 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(),
|
||||
@@ -409,9 +383,7 @@ class TimeEntryController extends Controller
|
||||
false,
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
$showBillableRate
|
||||
);
|
||||
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery->clone(),
|
||||
@@ -422,9 +394,7 @@ class TimeEntryController extends Controller
|
||||
true,
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
$showBillableRate
|
||||
);
|
||||
$currency = $organization->currency;
|
||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||
@@ -507,7 +477,7 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* @return Builder<TimeEntry>
|
||||
*/
|
||||
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
|
||||
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
|
||||
{
|
||||
$timeEntriesQuery = TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization');
|
||||
|
||||
@@ -6,7 +6,6 @@ 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;
|
||||
@@ -129,18 +128,6 @@ 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -218,22 +205,4 @@ 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,7 +7,6 @@ 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;
|
||||
@@ -165,18 +164,6 @@ 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -224,22 +211,4 @@ 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,7 +5,6 @@ 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;
|
||||
@@ -147,18 +146,6 @@ 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -186,22 +173,4 @@ 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,7 +5,6 @@ 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;
|
||||
@@ -134,18 +133,6 @@ 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -183,22 +170,4 @@ 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,7 +4,6 @@ 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;
|
||||
@@ -12,10 +11,8 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -26,7 +23,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|RuleContract>>
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -139,18 +136,6 @@ 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -168,22 +153,4 @@ 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,10 +58,6 @@ 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,26 +77,6 @@ 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,7 +6,6 @@ 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;
|
||||
@@ -60,10 +59,6 @@ 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.
|
||||
*
|
||||
@@ -120,10 +115,6 @@ 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;
|
||||
}
|
||||
@@ -149,8 +140,6 @@ 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,7 +6,6 @@ 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;
|
||||
@@ -42,7 +41,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, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
||||
{
|
||||
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||
$group1Select = null;
|
||||
@@ -57,14 +56,15 @@ 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 ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||
' 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'
|
||||
);
|
||||
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, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
||||
{
|
||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes);
|
||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
|
||||
|
||||
$keysGroup1 = [];
|
||||
$keysGroup2 = [];
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<?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,8 +118,7 @@
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": [
|
||||
"laravel/telescope",
|
||||
"nwidart/laravel-modules"
|
||||
"laravel/telescope"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Nwidart\Modules\LaravelModulesServiceProvider;
|
||||
|
||||
return [
|
||||
|
||||
@@ -198,7 +197,6 @@ 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,16 +153,6 @@ 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,8 +7,11 @@ 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);
|
||||
@@ -25,9 +28,13 @@ 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(),
|
||||
@@ -38,7 +45,9 @@ 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,8 +22,12 @@ 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`
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -34,8 +38,12 @@ 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`
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -46,8 +54,12 @@ 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 }) => {
|
||||
@@ -57,7 +69,9 @@ 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(),
|
||||
]);
|
||||
});
|
||||
@@ -71,7 +85,9 @@ 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([
|
||||
@@ -87,7 +103,8 @@ 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,7 +46,9 @@ 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' })
|
||||
@@ -54,7 +56,9 @@ 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/') &&
|
||||
@@ -66,12 +70,15 @@ 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
|
||||
@@ -106,7 +113,8 @@ test('test that organization format settings can be updated', async ({ page }) =
|
||||
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'
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -124,7 +132,8 @@ test('test that organization format settings can be updated', async ({ page }) =
|
||||
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'
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -160,14 +169,19 @@ test('test that organization format settings can be updated', async ({ page }) =
|
||||
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);
|
||||
@@ -199,8 +213,10 @@ test('test that format settings are reflected in the dashboard', async ({ page }
|
||||
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'
|
||||
),
|
||||
]);
|
||||
@@ -216,12 +232,16 @@ test('test that format settings are reflected in the dashboard', async ({ page }
|
||||
// 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,32 +1,34 @@
|
||||
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');
|
||||
@@ -34,37 +36,34 @@ 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,7 +12,8 @@ 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();
|
||||
@@ -35,7 +36,9 @@ 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();
|
||||
|
||||
@@ -52,7 +55,8 @@ 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,8 +9,11 @@ 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);
|
||||
@@ -28,10 +31,16 @@ 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(),
|
||||
@@ -42,11 +51,14 @@ 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);
|
||||
@@ -75,8 +87,11 @@ 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();
|
||||
@@ -89,11 +104,15 @@ 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/') &&
|
||||
@@ -105,7 +124,8 @@ 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,6 +2,8 @@ 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');
|
||||
}
|
||||
@@ -29,10 +31,7 @@ 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
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill(`Time entry for ${projectName}`);
|
||||
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
|
||||
|
||||
await page.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByText(projectName).click();
|
||||
@@ -44,9 +43,7 @@ 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)
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -55,10 +52,7 @@ 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
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill(`Time entry with tag ${tagName}`);
|
||||
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
|
||||
|
||||
// Add tag
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
@@ -75,19 +69,12 @@ 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
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
|
||||
await page.getByTestId('time_entry_description').fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
|
||||
|
||||
// Set billable status
|
||||
await page.getByRole('button', { name: 'Non-Billable' }).click();
|
||||
@@ -116,22 +103,19 @@ 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.getByRole('dialog').getByText(project1).click();
|
||||
await page.getByText(project1).click();
|
||||
|
||||
await Promise.all([
|
||||
// escape
|
||||
page.keyboard.press('Escape'),
|
||||
// wait for API request to finish
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
),
|
||||
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.getByTestId('reporting_view').getByText(project1)).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible();
|
||||
await expect(page.getByText(project1)).toBeVisible();
|
||||
await expect(page.getByText(project2)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that tag filtering works in reporting', async ({ page }) => {
|
||||
@@ -154,14 +138,11 @@ 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.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that billable status filtering works in reporting', async ({ page }) => {
|
||||
@@ -179,16 +160,14 @@ 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.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
test('test that detailed view shows time entries correctly', async ({ page }) => {
|
||||
const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ 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();
|
||||
@@ -25,9 +27,13 @@ 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,8 +7,11 @@ 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);
|
||||
@@ -26,7 +29,9 @@ 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();
|
||||
|
||||
@@ -50,9 +55,13 @@ 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(),
|
||||
@@ -67,9 +76,13 @@ 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(),
|
||||
@@ -80,11 +93,14 @@ 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,7 +25,9 @@ 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
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -36,7 +38,9 @@ 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);
|
||||
@@ -52,7 +56,9 @@ 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 ({
|
||||
@@ -65,14 +71,17 @@ 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 &&
|
||||
@@ -81,7 +90,8 @@ 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([])
|
||||
);
|
||||
}),
|
||||
]);
|
||||
@@ -97,14 +107,17 @@ 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 &&
|
||||
@@ -113,13 +126,16 @@ 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);
|
||||
@@ -136,7 +152,8 @@ test('test that adding a new tag to an existing time entry works', async ({ page
|
||||
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
|
||||
);
|
||||
}),
|
||||
@@ -146,7 +163,8 @@ test('test that adding a new tag to an existing time entry works', async ({ page
|
||||
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 &&
|
||||
@@ -169,14 +187,17 @@ 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 &&
|
||||
@@ -187,7 +208,9 @@ 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);
|
||||
@@ -202,7 +225,8 @@ 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 &&
|
||||
@@ -216,7 +240,9 @@ 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);
|
||||
@@ -229,7 +255,8 @@ test('test that starting a time entry from the overview works', async ({ page })
|
||||
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
|
||||
@@ -245,7 +272,8 @@ test('test that starting a time entry from the overview works', async ({ page })
|
||||
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
|
||||
@@ -256,7 +284,9 @@ test('test that starting a time entry from the overview works', async ({ page })
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -272,12 +302,16 @@ test('test that deleting a time entry from the overview works', async ({ page })
|
||||
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
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -288,14 +322,18 @@ 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,15 +24,22 @@ 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',
|
||||
@@ -55,29 +62,47 @@ 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, {
|
||||
@@ -96,7 +121,9 @@ 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),
|
||||
@@ -111,16 +138,19 @@ test('test that starting and updating the time while running works', async ({ pa
|
||||
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'),
|
||||
@@ -128,11 +158,16 @@ test('test that starting and updating the time while running works', async ({ pa
|
||||
|
||||
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([
|
||||
@@ -142,13 +177,18 @@ 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([
|
||||
@@ -158,7 +198,10 @@ 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'
|
||||
);
|
||||
@@ -176,7 +219,10 @@ 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'
|
||||
);
|
||||
@@ -193,7 +239,9 @@ 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([
|
||||
@@ -201,7 +249,10 @@ test('test that entering a time starts the timer on enter', async ({ page }) =>
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -222,10 +273,15 @@ 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,7 +1,9 @@
|
||||
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) {
|
||||
@@ -18,7 +20,8 @@ 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 &&
|
||||
@@ -26,23 +29,30 @@ 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 &&
|
||||
@@ -51,7 +61,8 @@ export async function stoppedTimeEntryResponse(page: Page, { description = '', t
|
||||
(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,7 +4,8 @@ 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,21 +23,18 @@ 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.8.2",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
"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",
|
||||
"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}'"
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@@ -48,7 +46,7 @@
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
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,8 +8,12 @@ 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();
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
public/fonts/Outfit-Bold.ttf
Normal file
BIN
public/fonts/Outfit-Bold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-ExtraBold.ttf
Normal file
BIN
public/fonts/Outfit-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-Medium.ttf
Normal file
BIN
public/fonts/Outfit-Medium.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-Regular.ttf
Normal file
BIN
public/fonts/Outfit-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-SemiBold.ttf
Normal file
BIN
public/fonts/Outfit-SemiBold.ttf
Normal file
Binary file not shown.
@@ -160,17 +160,30 @@ body {
|
||||
}
|
||||
|
||||
|
||||
/* Inter Variable Font with browser compatibility considerations */
|
||||
@font-face {
|
||||
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';
|
||||
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;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -192,7 +205,7 @@ body {
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--color-border-tertiary);
|
||||
--input: var(--theme-color-input-background);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-400);
|
||||
--chart-2: var(--color-accent-500);
|
||||
@@ -219,7 +232,7 @@ body {
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--color-border-tertiary);
|
||||
--input: var(--theme-color-input-background);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-200);
|
||||
--chart-2: var(--color-accent-300);
|
||||
|
||||
@@ -14,7 +14,8 @@ 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,7 +24,9 @@ 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,6 +1,10 @@
|
||||
<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 {
|
||||
@@ -14,20 +18,28 @@ 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(),
|
||||
@@ -50,7 +62,10 @@ 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" />
|
||||
@@ -98,7 +113,8 @@ 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>
|
||||
@@ -114,8 +130,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>
|
||||
@@ -127,7 +143,8 @@ 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>
|
||||
@@ -139,22 +156,27 @@ 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>
|
||||
@@ -166,22 +188,27 @@ 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,8 +1,7 @@
|
||||
<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,5 +1,9 @@
|
||||
<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,10 +24,17 @@ 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,7 +20,9 @@ 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() {
|
||||
@@ -35,7 +37,9 @@ 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,8 +20,11 @@ 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,7 +9,8 @@ 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,12 +38,15 @@ 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'
|
||||
);
|
||||
@@ -62,7 +65,9 @@ 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,8 +35,12 @@ 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';
|
||||
<form.Field
|
||||
name="confirmDelete"
|
||||
:validators="{
|
||||
onSubmit: ({value}) => {
|
||||
if (!value) {
|
||||
return 'You must confirm that you understand the consequences of this action';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
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 @@ const close = () => {
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
</template>
|
||||
@@ -54,7 +54,10 @@ 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 {
|
||||
@@ -93,7 +96,10 @@ 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 '';
|
||||
@@ -137,14 +143,22 @@ 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,7 +11,10 @@ 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';
|
||||
@@ -41,10 +44,14 @@ 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;
|
||||
}
|
||||
|
||||
@@ -93,15 +100,21 @@ 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" />
|
||||
@@ -141,7 +154,8 @@ 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
|
||||
@@ -155,13 +169,17 @@ 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,15 +36,17 @@ 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>
|
||||
@@ -57,9 +59,8 @@ 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,36 +27,40 @@ const mergeMember = useMutation({
|
||||
if (organizationId === null) {
|
||||
throw new Error('No current organization id - create report');
|
||||
}
|
||||
return await api.mergeMember(
|
||||
{
|
||||
member_id: newMemberId,
|
||||
return await api.mergeMember({
|
||||
member_id: newMemberId,
|
||||
}, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
member: props.member.id
|
||||
},
|
||||
{
|
||||
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>
|
||||
@@ -68,14 +72,10 @@ 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,7 +86,9 @@ 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,17 +1,7 @@
|
||||
<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,7 +22,9 @@ 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,9 +10,12 @@ 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,9 +86,13 @@ 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
|
||||
@@ -112,8 +116,12 @@ 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,39 +1,41 @@
|
||||
<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() || "");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +44,7 @@ withDefaults(
|
||||
border?: boolean;
|
||||
}>(),
|
||||
{
|
||||
border: true,
|
||||
border: true
|
||||
}
|
||||
);
|
||||
|
||||
@@ -60,13 +62,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;
|
||||
}
|
||||
}
|
||||
@@ -93,16 +95,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>
|
||||
|
||||
@@ -120,13 +122,16 @@ 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"
|
||||
@@ -150,12 +155,19 @@ 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,7 +3,11 @@ 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';
|
||||
@@ -60,7 +64,9 @@ 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';
|
||||
});
|
||||
@@ -81,7 +87,8 @@ 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" />
|
||||
@@ -115,7 +122,8 @@ 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>
|
||||
@@ -129,7 +137,9 @@ 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,5 +1,9 @@
|
||||
<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,7 +7,12 @@ 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';
|
||||
@@ -19,11 +24,15 @@ 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());
|
||||
@@ -43,11 +52,19 @@ 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,9 +12,15 @@ 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,11 +26,14 @@ 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() {
|
||||
@@ -64,6 +67,7 @@ const billableRateInfo = computed(() => {
|
||||
});
|
||||
|
||||
const showEditProjectModal = ref(false);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -82,10 +86,15 @@ 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>
|
||||
@@ -102,8 +111,10 @@ 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,7 +2,10 @@
|
||||
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';
|
||||
@@ -54,7 +57,9 @@ 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,7 +2,10 @@
|
||||
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';
|
||||
@@ -26,7 +29,10 @@ 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;
|
||||
@@ -78,14 +84,20 @@ 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,7 +22,9 @@ 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,16 +28,24 @@ 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,7 +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">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,7 +31,9 @@ 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,7 +5,10 @@ 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';
|
||||
@@ -77,7 +80,10 @@ 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" />
|
||||
@@ -89,13 +95,19 @@ 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,7 +94,10 @@ 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" />
|
||||
@@ -106,10 +109,14 @@ 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,9 +31,13 @@ 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,15 +21,25 @@ 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,9 +8,15 @@ 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,7 +57,9 @@ 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">
|
||||
@@ -73,9 +75,14 @@ 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,7 +2,11 @@
|
||||
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';
|
||||
@@ -15,7 +19,14 @@ 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');
|
||||
|
||||
@@ -116,7 +127,7 @@ const option = computed(() => ({
|
||||
fontWeight: 600,
|
||||
color: labelColor.value,
|
||||
margin: 16,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
},
|
||||
axisTick: {
|
||||
lineStyle: {
|
||||
@@ -128,7 +139,7 @@ const option = computed(() => ({
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: labelColor.value,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
@@ -163,7 +174,9 @@ 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,11 +27,15 @@ 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
|
||||
@@ -39,20 +43,27 @@ 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,5 +1,9 @@
|
||||
<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;
|
||||
@@ -15,19 +19,30 @@ 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 { Button } from '@/Components/ui/button';
|
||||
import Badge from '@/packages/ui/src/Badge.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
icon: Component;
|
||||
@@ -12,29 +12,32 @@ import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const activeClass = computed(() => {
|
||||
if (props.active) {
|
||||
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 'border-accent-300/50 bg-accent-300/10 hover:bg-accent-300/20';
|
||||
}
|
||||
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>
|
||||
<Button variant="outline" size="sm" :class="twMerge(activeClass)">
|
||||
<component :is="icon" :class="iconClass"></component>
|
||||
<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>
|
||||
<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>
|
||||
</Button>
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -8,10 +8,12 @@ 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,5 +1,10 @@
|
||||
<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';
|
||||
@@ -11,7 +16,6 @@ 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';
|
||||
@@ -29,7 +33,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, watch } from 'vue';
|
||||
import { computed, type ComputedRef, inject, onMounted, ref } from 'vue';
|
||||
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import {
|
||||
@@ -38,7 +42,11 @@ 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';
|
||||
@@ -46,9 +54,6 @@ 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>(
|
||||
@@ -66,26 +71,19 @@ 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(),
|
||||
@@ -93,15 +91,26 @@ 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,
|
||||
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
||||
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
|
||||
member_id:
|
||||
getCurrentRole() === 'employee'
|
||||
? getCurrentMembershipId()
|
||||
: undefined,
|
||||
};
|
||||
return params;
|
||||
}
|
||||
@@ -119,7 +128,9 @@ 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;
|
||||
}
|
||||
@@ -137,8 +148,14 @@ 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';
|
||||
@@ -182,7 +199,10 @@ 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,
|
||||
},
|
||||
}),
|
||||
@@ -215,12 +235,17 @@ 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,
|
||||
@@ -249,7 +274,10 @@ const tableData = computed(() => {
|
||||
return {
|
||||
seconds: el.seconds,
|
||||
cost: el.cost,
|
||||
description: getNameForReportingRowEntry(el.key, entry.grouped_type),
|
||||
description: getNameForReportingRowEntry(
|
||||
el.key,
|
||||
entry.grouped_type
|
||||
),
|
||||
};
|
||||
}) ?? [],
|
||||
};
|
||||
@@ -268,15 +296,20 @@ 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-3">
|
||||
<div
|
||||
class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-4">
|
||||
<div class="text-sm font-medium">Filters</div>
|
||||
<MemberMultiselectDropdown v-model="selectedMembers" @submit="updateReporting">
|
||||
<MemberMultiselectDropdown
|
||||
v-model="selectedMembers"
|
||||
@submit="updateReporting">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
:count="selectedMembers.length"
|
||||
@@ -285,7 +318,9 @@ 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"
|
||||
@@ -294,7 +329,9 @@ 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"
|
||||
@@ -303,7 +340,9 @@ 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"
|
||||
@@ -348,15 +387,14 @@ 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
|
||||
@@ -370,26 +408,37 @@ 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>
|
||||
@@ -407,11 +456,13 @@ 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,
|
||||
@@ -420,7 +471,8 @@ 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(
|
||||
@@ -438,13 +490,16 @@ 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-medium">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>
|
||||
</div>
|
||||
<div class="px-2 lg:px-4">
|
||||
<ReportingPieChart :data="groupedPieChartData"></ReportingPieChart>
|
||||
<ReportingPieChart
|
||||
:data="groupedPieChartData"></ReportingPieChart>
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
|
||||
@@ -14,7 +14,14 @@ 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');
|
||||
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
<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,7 +32,10 @@ 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"
|
||||
@@ -54,17 +57,13 @@ 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,11 +12,17 @@ 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