mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 16:22:16 +01:00
Compare commits
3 Commits
feature/ad
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42fa680bf6 | ||
|
|
b647a6af71 | ||
|
|
f223bd23c4 |
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,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Auth;
|
||||
|
||||
use App\Mail\AuthApiTokenExpirationReminderMail;
|
||||
use App\Mail\AuthApiTokenExpiredMail;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class AuthSendReminderForExpiringApiTokensCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'auth:send-mails-expiring-api-tokens '.
|
||||
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Sends emails about expiring API tokens, one week before and when they expired.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
if ($dryRun) {
|
||||
$this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');
|
||||
}
|
||||
|
||||
$this->comment('Sending reminder emails about expiring API tokens...');
|
||||
$sentMails = 0;
|
||||
Token::query()
|
||||
->where('expires_at', '<=', Carbon::now()->addDays(7))
|
||||
->whereNull('reminder_sent_at')
|
||||
->with([
|
||||
'client',
|
||||
'user',
|
||||
])
|
||||
->whereHas('user', function (Builder $query): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', false);
|
||||
})
|
||||
->isApiToken(true)
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
|
||||
/** @var Collection<int, Token> $tokens */
|
||||
foreach ($tokens as $token) {
|
||||
$user = $token->user;
|
||||
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') reminding about API token '.$token->getKey());
|
||||
$sentMails++;
|
||||
if (! $dryRun) {
|
||||
Mail::to($user->email)
|
||||
->queue(new AuthApiTokenExpirationReminderMail($token, $user));
|
||||
$token->reminder_sent_at = Carbon::now();
|
||||
$token->save();
|
||||
}
|
||||
}
|
||||
});
|
||||
$this->comment('Finished sending '.$sentMails.' expiring API token emails...');
|
||||
|
||||
$this->comment('Sent emails about expired API tokens');
|
||||
$sentMails = 0;
|
||||
Token::query()
|
||||
->where('expires_at', '<=', Carbon::now())
|
||||
->whereNull('expired_info_sent_at')
|
||||
->with([
|
||||
'client',
|
||||
'user',
|
||||
])
|
||||
->whereHas('user', function (Builder $query): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', false);
|
||||
})
|
||||
->isApiToken(true)
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
|
||||
/** @var Collection<int, Token> $tokens */
|
||||
foreach ($tokens as $token) {
|
||||
$user = $token->user;
|
||||
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') about expired API token '.$token->getKey());
|
||||
$sentMails++;
|
||||
if (! $dryRun) {
|
||||
Mail::to($user->email)
|
||||
->queue(new AuthApiTokenExpiredMail($token, $user));
|
||||
$token->expired_info_sent_at = Carbon::now();
|
||||
$token->save();
|
||||
}
|
||||
}
|
||||
});
|
||||
$this->comment('Finished sending '.$sentMails.' expired API token emails...');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,6 @@ class Kernel extends ConsoleKernel
|
||||
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
|
||||
->everyTenMinutes();
|
||||
|
||||
$schedule->command('auth:send-mails-expiring-api-tokens')
|
||||
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
|
||||
->everyTenMinutes();
|
||||
|
||||
$schedule->command('self-host:check-for-update')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
||||
->twiceDaily();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -15,7 +15,6 @@ use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\BulkAction;
|
||||
use Filament\Tables\Actions\DeleteAction;
|
||||
use Filament\Tables\Actions\DeleteBulkAction;
|
||||
use Filament\Tables\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
@@ -76,8 +75,7 @@ class FailedJobResource extends Resource
|
||||
->filters([])
|
||||
->bulkActions([
|
||||
BulkAction::make('retry')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->label('Retry selected')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->action(function (Collection $records): void {
|
||||
/** @var FailedJob $record */
|
||||
@@ -89,13 +87,11 @@ class FailedJobResource extends Resource
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
DeleteBulkAction::make(),
|
||||
])
|
||||
->actions([
|
||||
DeleteAction::make(),
|
||||
ViewAction::make(),
|
||||
DeleteAction::make('Delete'),
|
||||
ViewAction::make('View'),
|
||||
Action::make('retry')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->action(function (FailedJob $record): void {
|
||||
@@ -113,6 +109,7 @@ class FailedJobResource extends Resource
|
||||
return [
|
||||
'index' => ListFailedJobs::route('/'),
|
||||
'view' => ViewFailedJobs::route('/{record}'),
|
||||
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ namespace App\Filament\Resources\FailedJobResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FailedJobResource;
|
||||
use App\Models\FailedJob;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Actions\Action;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
@@ -19,8 +19,7 @@ class ListFailedJobs extends ListRecords
|
||||
{
|
||||
return [
|
||||
Action::make('retry_all')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->label('Retry all')
|
||||
->label('Retry all failed Jobs')
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
Artisan::call('queue:retry all');
|
||||
@@ -31,8 +30,7 @@ class ListFailedJobs extends ListRecords
|
||||
}),
|
||||
|
||||
Action::make('delete_all')
|
||||
->icon('heroicon-o-trash')
|
||||
->label('Delete all')
|
||||
->label('Delete all failed Jobs')
|
||||
->requiresConfirmation()
|
||||
->color('danger')
|
||||
->action(function (): void {
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TokenResource\Pages;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
@@ -105,11 +106,17 @@ class TokenResource extends Resource
|
||||
->queries(
|
||||
true: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
return $query->isApiToken();
|
||||
return $query->whereHas('client', function (Builder $query) {
|
||||
/** @var Builder<Client> $query */
|
||||
return $query->whereJsonContains('grant_types', 'personal_access');
|
||||
});
|
||||
},
|
||||
false: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
return $query->isApiToken(false);
|
||||
return $query->whereHas('client', function (Builder $query) {
|
||||
/** @var Builder<Client> $query */
|
||||
return $query->whereJsonDoesntContain('grant_types', 'personal_access');
|
||||
});
|
||||
},
|
||||
blank: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class AuthApiTokenExpirationReminderMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public Token $token;
|
||||
|
||||
public User $user;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Token $token, User $user)
|
||||
{
|
||||
$this->token = $token;
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message.
|
||||
*/
|
||||
public function build(): self
|
||||
{
|
||||
return $this->markdown('emails.auth-api-expiration-reminder', [
|
||||
'profileUrl' => URL::to('user/profile'),
|
||||
'tokenName' => $this->token->name,
|
||||
])
|
||||
->subject(__('Your API token will expire in 7 days!'));
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class AuthApiTokenExpiredMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public Token $token;
|
||||
|
||||
public User $user;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Token $token, User $user)
|
||||
{
|
||||
$this->token = $token;
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message.
|
||||
*/
|
||||
public function build(): self
|
||||
{
|
||||
return $this->markdown('emails.auth-api-token-expired', [
|
||||
'profileUrl' => URL::to('user/profile'),
|
||||
'tokenName' => $this->token->name,
|
||||
])
|
||||
->subject(__('Your API token has expired!'));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ namespace App\Models\Passport;
|
||||
|
||||
use App\Models\User;
|
||||
use Database\Factories\Passport\TokenFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -19,15 +18,11 @@ use Laravel\Passport\Token as PassportToken;
|
||||
* @property null|string $name
|
||||
* @property array<string> $scopes
|
||||
* @property bool $revoked
|
||||
* @property Carbon|null $reminder_sent_at
|
||||
* @property Carbon|null $expired_info_sent_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Carbon|null $expires_at
|
||||
* @property-read Client|null $client
|
||||
* @property-read User|null $user
|
||||
*
|
||||
* @method Builder<Token> isApiToken(bool $isApiToken = true)
|
||||
*/
|
||||
class Token extends PassportToken
|
||||
{
|
||||
@@ -57,40 +52,4 @@ class Token extends PassportToken
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'scopes' => 'array',
|
||||
'revoked' => 'bool',
|
||||
'expires_at' => 'datetime',
|
||||
'reminder_sent_at' => 'datetime',
|
||||
'expired_info_sent_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<static> $query
|
||||
* @return Builder<static>
|
||||
*/
|
||||
public function scopeIsApiToken(Builder $query, bool $isApiToken = true): Builder
|
||||
{
|
||||
if ($isApiToken) {
|
||||
return $query->whereHas('client', function (Builder $query): void {
|
||||
/** @var Builder<Client> $query */
|
||||
$query->whereJsonContains('grant_types', 'personal_access');
|
||||
});
|
||||
} else {
|
||||
return $query->whereHas('client', function (Builder $query): void {
|
||||
/** @var Builder<Client> $query */
|
||||
$query->whereJsonDoesntContain('grant_types', 'personal_access');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Brick\Money\Money;
|
||||
|
||||
class CurrencyService
|
||||
@@ -375,12 +374,4 @@ class CurrencyService
|
||||
|
||||
return $currencyCode;
|
||||
}
|
||||
|
||||
public function getRandomCurrencyCode(): string
|
||||
{
|
||||
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
|
||||
$currencyCodes = array_keys($currencies);
|
||||
|
||||
return $currencyCodes[array_rand($currencyCodes)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
/*
|
||||
|
||||
@@ -6,7 +6,6 @@ return [
|
||||
|
||||
'tasks' => [
|
||||
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
|
||||
'auth_send_mails_expiring_api_tokens' => (bool) env('SCHEDULING_TASK_AUTH_SEND_MAILS_EXPIRING_API_TOKENS', true),
|
||||
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
|
||||
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
|
||||
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),
|
||||
|
||||
@@ -11,7 +11,6 @@ use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\CurrencyService;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@@ -28,7 +27,7 @@ class OrganizationFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->unique()->company(),
|
||||
'currency' => app(CurrencyService::class)->getRandomCurrencyCode(),
|
||||
'currency' => $this->faker->currencyCode(),
|
||||
'billable_rate' => null,
|
||||
'user_id' => User::factory(),
|
||||
'personal_team' => true,
|
||||
|
||||
@@ -36,22 +36,6 @@ class ClientFactory extends BaseClientFactory
|
||||
];
|
||||
}
|
||||
|
||||
public function desktopClient(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'name' => 'Desktop',
|
||||
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'authorization_code', 'implicit'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function apiClient(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'name' => 'API',
|
||||
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'client_credentials', 'personal_access'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function personalAccessClient(): self
|
||||
{
|
||||
return $this->state(function (array $attributes) {
|
||||
|
||||
@@ -31,8 +31,6 @@ class TokenFactory extends Factory
|
||||
'created_at' => $this->faker->dateTime,
|
||||
'updated_at' => $this->faker->dateTime,
|
||||
'expires_at' => $this->faker->dateTime,
|
||||
'reminder_sent_at' => null,
|
||||
'expired_info_sent_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
|
||||
$table->dateTime('reminder_sent_at')->nullable();
|
||||
$table->dateTime('expired_info_sent_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
|
||||
$table->dropColumn('reminder_sent_at');
|
||||
$table->dropColumn('expired_info_sent_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user