Compare commits

...

63 Commits

Author SHA1 Message Date
Constantin Graf
07823291ae Removed default healthcheck in prod Dockerfile 2024-09-05 13:07:01 +02:00
Gregor Vostrak
75012ea020 Update README.md 2024-09-04 17:37:08 +02:00
Gregor Vostrak
49de8d0900 remove dev setup instructions from the readme and link self-hosting 2024-09-04 17:28:44 +02:00
Constantin Graf
156d2ff1a0 Add auditing 2024-09-03 14:26:01 +02:00
Constantin Graf
a01e1d6b0b Add billable rate calculation to creation and deletion of project members 2024-09-03 13:10:43 +02:00
Constantin Graf
9df91f4e4a Fix billiable rate in updateMultiple time entries (ST-396) 2024-09-03 13:09:09 +02:00
Gregor Vostrak
e538fec7c7 improve sidebar scrollbars for firefox 2024-08-29 14:55:45 +02:00
Gregor Vostrak
aee5ea456e fix overflow issue 2024-08-29 14:55:45 +02:00
Gregor Vostrak
2c0ab5e15a add update notification to sidebar, fix aborted requests on navigate 2024-08-29 14:55:45 +02:00
Constantin Graf
0245eccaeb Fixed broken test 2024-08-27 21:31:09 +02:00
Constantin Graf
ee77de04ef Added export endpoint and solidtime import; Enhanced toggl import 2024-08-27 21:31:09 +02:00
Gregor Vostrak
056a63e193 fix desktop version update urls 2024-08-27 18:52:09 +02:00
Gregor Vostrak
024d841024 add desktop versions infos, make package publish actions only run on manual trigger 2024-08-27 17:47:22 +02:00
Gregor Vostrak
597f9ce802 fix time entry aggregate mass delete function 2024-08-27 17:47:22 +02:00
Gregor Vostrak
18ac9acc2a chore: bump api package version 2024-08-27 17:47:22 +02:00
Gregor Vostrak
f6d9dfa6bb expose createApiClient method in api package to public 2024-08-27 17:47:22 +02:00
Gregor Vostrak
64d422f5f7 force publish ui package 2024-08-27 17:47:22 +02:00
Gregor Vostrak
b3b8b9fba9 fix formatting of github action files workflow_dispatch 2024-08-27 17:47:22 +02:00
Gregor Vostrak
e981d6bc01 chore: bump ui package version 2024-08-27 17:47:22 +02:00
Gregor Vostrak
859833452f add daily duration to header, fix dropdown overflows, add time dropdown to duration select 2024-08-27 17:47:22 +02:00
Gregor Vostrak
33d139e3aa add mass updates to time entry aggregate rows, make package actions run on manual dispatch 2024-08-27 17:47:22 +02:00
Gregor Vostrak
0c05ad240d install root dependencies for building api package 2024-08-27 17:47:22 +02:00
Gregor Vostrak
4ad68b4f4e change github action checkout path to prevent dependencies being loaded from the parent 2024-08-27 17:47:22 +02:00
Gregor Vostrak
249b1b5820 cleanup and fix formatting for utils and packages 2024-08-27 17:47:22 +02:00
Gregor Vostrak
1328692faf fix ui exports, change api package bunder to vite, fix type exports 2024-08-27 17:47:22 +02:00
Gregor Vostrak
35c65d3bf0 move MainContainer Component to ui package and fix types 2024-08-27 17:47:22 +02:00
Gregor Vostrak
c3cad88949 add TimeEntryGroupedTable to exported components 2024-08-27 17:47:22 +02:00
Gregor Vostrak
f4d4ea8b98 explicitly define exported components 2024-08-27 17:47:22 +02:00
Gregor Vostrak
05ece9b0ee clean up ui package dev dependencies 2024-08-27 17:47:22 +02:00
Gregor Vostrak
571054b816 install root project dependencies for building ui package 2024-08-27 17:47:22 +02:00
Gregor Vostrak
f014137623 move multiselect components, week start and timezon functions to ui package 2024-08-27 17:47:22 +02:00
Gregor Vostrak
b2d327e8b1 add heroicons and move all ui package dependencies to peerDependencies 2024-08-27 17:47:22 +02:00
Gregor Vostrak
c6ee2b5131 add missing dayjs dependency to ui package 2024-08-27 17:47:22 +02:00
Gregor Vostrak
b689784701 add repository fields to package.json of api and ui packages 2024-08-27 17:47:22 +02:00
Gregor Vostrak
b375cba5f7 fix working directory in github actions for ui and api packages 2024-08-27 17:47:22 +02:00
Gregor Vostrak
635954f81d move ui and api to seperate packages and add npm actions for them 2024-08-27 17:47:22 +02:00
Constantin Graf
b7c9aa6f28 ST-370: Fixed error when sending unknown fields in request 2024-08-23 16:53:00 +02:00
Gregor Vostrak
87b114a32a fix formatting for hours 2024-08-20 23:38:36 +02:00
Gregor Vostrak
00e095ec4b fix token invalidation detection 2024-08-20 16:27:52 +02:00
Gregor Vostrak
b741105cfa only update the current time entry when the description was actually changed, not on all blur 2024-08-08 17:36:20 +02:00
Gregor Vostrak
16203ec748 fix hiding of existing members in the member create modal 2024-08-08 17:36:20 +02:00
Gregor Vostrak
06a35cb447 disable zodios request/response validation in runtime and use server errors instead 2024-08-08 16:11:14 +02:00
Gregor Vostrak
7c1b828ad3 fix vite config for authorization page 2024-08-05 16:49:24 +02:00
Gregor Vostrak
ea90b0acb2 add custom passport authorize page 2024-08-05 16:49:24 +02:00
Gregor Vostrak
10cc5cf42a seperate project types, make tag dropdown location configurable, update api client 2024-08-05 16:49:24 +02:00
Constantin Graf
04bb8e50a7 Renamed user member endpoint and removed pagination 2024-08-05 16:49:24 +02:00
Gregor Vostrak
6aef8856f5 fix wrong secondarybutton import 2024-08-05 16:49:24 +02:00
Gregor Vostrak
06fef6e40f refactor timetracker to seperate data and ui logic 2024-08-05 16:49:24 +02:00
Constantin Graf
a9c874e540 Added pagination config for filament 2024-08-05 16:49:24 +02:00
Constantin Graf
21207a4058 Added me endpoints 2024-08-05 16:49:24 +02:00
Constantin Graf
0e7dec2f40 Updated scramble 2024-08-05 16:49:24 +02:00
Gregor Vostrak
99c652a61b refactor required time entry emits to props 2024-08-05 16:49:24 +02:00
Gregor Vostrak
1e4f0afa67 use prop function createTag instead of event to make sure it is handled by the parent 2024-08-05 16:49:24 +02:00
Gregor Vostrak
655723db49 refactor tag components and tagCreate events, change global week_start and timezone settings, fix pie charts 2024-08-05 16:49:24 +02:00
Gregor Vostrak
10d8540e6c refactor to common MoreOptionsDropdown component for shared ui 2024-08-05 16:49:24 +02:00
Gregor Vostrak
cbdbcef9eb move time entries grouped table to its own component 2024-08-05 16:49:24 +02:00
Gregor Vostrak
a519c119d4 refactor time entry and projecttaskdropdown components to not rely on pinia stores 2024-08-05 16:49:24 +02:00
Gregor Vostrak
375cee7589 fix select behaviour in project member dropdown, fixes ST-308 2024-08-05 16:49:24 +02:00
Constantin Graf
ba07616111 Added storage link to docker image 2024-07-24 13:54:54 +02:00
Constantin Graf
63323d86c3 Added tests for FailedJobResource and renamed to singular 2024-07-18 13:27:46 +02:00
Constantin Graf
8db0a7d25e Added mail to inform users about still running time entries 2024-07-18 13:27:46 +02:00
Constantin Graf
855db81104 Added failed jobs to admin panel 2024-07-18 13:27:46 +02:00
Constantin Graf
055d93f7a3 Published mail layout and added logo 2024-07-18 13:27:46 +02:00
319 changed files with 13773 additions and 2718 deletions

View File

@@ -3,6 +3,7 @@ APP_ENV=local
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true
APP_URL=https://solidtime.test
AUDITING_ENABLED=true
SUPER_ADMINS=admin@example.com

29
.github/workflows/npm-publish-api.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Publish API package to NPM
on:
workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm ci
working-directory: ./resources/js/packages/api
- name: Build package
run: npm run build
working-directory: ./resources/js/packages/api
- name: Publish Package
run: npm publish --provenance --access public
working-directory: ./resources/js/packages/api
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

29
.github/workflows/npm-publish-ui.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Publish UI package to NPM
on:
workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Install root project dependencies
run: npm ci
- name: Install package dependencies
run: npm ci
working-directory: ./resources/js/packages/ui
- name: Build package
run: npm run build
working-directory: ./resources/js/packages/ui
- name: Publish Package
run: npm publish --provenance --access public
working-directory: ./resources/js/packages/ui
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/.phpunit.cache
/node_modules
node_modules
dist
/public/build
/public/hot
/public/storage

View File

@@ -20,82 +20,13 @@ solidtime is a modern open-source time tracking application for Freelancers and
- Roles and permissions: Create and manage organizations
- Import: Import your time tracking data from other time tracking applications (Supported: Toggl, Clockify, Timeentry CSV)
## Local setup for development
## Self Hosting
**System requirements**
* Docker
If you are looking into self-hosting solidtime, you can find the guides [here](https://docs.solidtime.io/self-hosting/intro)
First you need to download or clone the repository f.e. with `git@github.com:solidtime-io/solidtime.git`.
We also have an examples repository [here](https://github.com/solidtime-io/self-hosting-examples)
After that, execute the following commands **inside the project folder**:
```bash
docker run --rm \
--pull=always \
-v "$(pwd)":/opt \
-w /opt \
laravelsail/php83-composer:latest \
bash -c "composer install --ignore-platform-reqs"
cp .env.example .env
./vendor/bin/sail up -d
./vendor/bin/sail artisan key:generate
./vendor/bin/sail artisan migrate:fresh --seed
./vendor/bin/sail php artisan passport:install
./vendor/bin/sail npm install
./vendor/bin/sail npm run build
```
Make sure to set the APP_PORT and VITE_PORT inside your `.env` file to a port that is not already used by your system.
By default the application will run on [localhost:8083](http://localhost:8083/)
### Setup with Reverse Proxy
**Additional System Requirements**
* Traefik 2 Reverse-Proxy (https://github.com/korridor/reverse-proxy-docker-traefik)
Add the following entry to your `/etc/hosts`
```
127.0.0.1 solidtime.test
127.0.0.1 playwright.solidtime.test
127.0.0.1 vite.solidtime.test
127.0.0.1 mail.solidtime.test
```
### Running E2E Tests
`./vendor/bin/sail up -d ` will automatically start a Playwright UI server that you can access at `https://playwright.solidtime.test`.
Make sure that you use HTTPS otherwise the resources will not be loaded correctly.
### Recording E2E Tests
To record E2E tests, you need to install and execute playwright locally (outside the Docker container) using:
```bash
npx playwright install
npx playwright codegen solidtime.test
```
### E2E Troubleshooting
If E2E tests are not working at all, make sure you do not have the Vite server running and just run `npm run build` to update the version.
If the E2E tests are not working consistently and fail with a timeout during the authentication, you might want to delete the `test-results/.auth` directory to force new test accounts to be created.
### Generate ZOD Client
The Zodius HTTP client is generated using the following command:
```bash
npm run zod:generate
```
If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)
## Contributing

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\TimeEntry;
use App\Mail\TimeEntryStillRunningMail;
use App\Models\TimeEntry;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
class TimeEntrySendStillRunningMailsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'time-entry:send-still-running-mails '.
' { --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 to users who have running time entries for more than 8 hours.';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->comment('Sending still running time entry emails...');
$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.');
}
$sentMails = 0;
TimeEntry::query()
->whereNull('end')
->where('start', '<', now()->subHours(8))
->whereNull('still_active_email_sent_at')
->with([
'user',
])
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) {
/** @var Collection<int, TimeEntry> $timeEntries */
foreach ($timeEntries as $timeEntry) {
$user = $timeEntry->user;
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') for time entry '.$timeEntry->getKey());
$sentMails++;
if (! $dryRun) {
Mail::to($user->email)
->queue(new TimeEntryStillRunningMail($timeEntry, $user));
$timeEntry->still_active_email_sent_at = Carbon::now();
$timeEntry->save();
}
}
});
$this->comment('Finished sending '.$sentMails.' still running time entry emails...');
return self::SUCCESS;
}
}

View File

@@ -14,7 +14,9 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
$schedule->command('time-entry:send-still-running-mails')
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
->everyTenMinutes();
}
/**

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Extensions\Auditing\Resolvers;
use Illuminate\Support\Facades\Request;
use OwenIt\Auditing\Contracts\Auditable;
use OwenIt\Auditing\Contracts\Resolver;
class CustomIpAddressResolver implements Resolver
{
private static function anonymizeIpAddress(string $ipAddress): string
{
/** @source https://stackoverflow.com/a/48777412 */
return preg_replace(
['/\.\d*$/', '/[\da-f]*:[\da-f]*$/'],
['.0', '0:0'],
$ipAddress
);
}
public static function resolve(Auditable $auditable): string
{
$ip = $auditable->preloadedResolverData['ip_address'] ?? Request::ip();
if ($ip !== null) {
$ip = self::anonymizeIpAddress($ip);
}
return $ip;
}
}

View File

@@ -27,13 +27,10 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
&& $type->isInstanceOf(PaginatedResourceCollection::class);
}
/**
* @param Generic $type
*/
public function toResponse(Type $type): ?Response
public function toSchema(Type $type): ?OpenApiObjectType
{
/** @var Type|null $collectingClassType */
$collectingClassType = $type->templateTypes[0];
$collectingClassType = $type->templateTypes[0] ?? null;
if (! $collectingClassType instanceof ObjectType) {
return null;
@@ -79,6 +76,21 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
);
$type->setRequired(['data', 'links', 'meta']);
return $type;
}
/**
* @param Generic $type
*/
public function toResponse(Type $type): ?Response
{
/** @var ObjectType|null $collectingClassType */
$collectingClassType = $type->templateTypes[0] ?? null;
if (! $collectingClassType instanceof ObjectType) {
return null;
}
$type = $this->toSchema($type);
return Response::make(200)
->description('Paginated set of `'.$this->components->uniqueSchemaName($collectingClassType->name).'`')
->setContent('application/json', Schema::fromType($type));

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\AuditResource\Pages;
use App\Models\Audit;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\PrettyJson;
class AuditResource extends Resource
{
protected static ?string $model = Audit::class;
protected static ?string $navigationIcon = 'heroicon-o-archive-box';
protected static ?string $navigationGroup = 'System';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('user_type')
->maxLength(255),
Forms\Components\TextInput::make('user_id'),
Forms\Components\TextInput::make('event')
->required()
->maxLength(255),
Forms\Components\TextInput::make('auditable_type')
->required()
->maxLength(255),
Forms\Components\TextInput::make('auditable_id')
->required(),
PrettyJson::make('old_values'),
PrettyJson::make('new_values'),
Forms\Components\Textarea::make('url'),
Forms\Components\TextInput::make('ip_address'),
Forms\Components\TextInput::make('user_agent')
->maxLength(1023),
Forms\Components\TextInput::make('tags')
->maxLength(255),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('event'),
Tables\Columns\TextColumn::make('auditable_type'),
Tables\Columns\TextColumn::make('auditable_id'),
IconColumn::make('was_command')
->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))
->boolean(),
Tables\Columns\TextColumn::make('created_at')
->sortable()
->dateTime(),
Tables\Columns\TextColumn::make('updated_at')
->sortable()
->dateTime(),
])
->filters([
//
])
->actions([
Tables\Actions\ViewAction::make(),
])
->bulkActions([
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListAudits::route('/'),
'create' => Pages\CreateAudit::route('/create'),
'view' => Pages\ViewAudit::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAudit extends CreateRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ListRecords;
class ListAudits extends ListRecords
{
protected static string $resource = AuditResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ViewRecord;
class ViewAudit extends ViewRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\FailedJobResource\Pages\ListFailedJobs;
use App\Filament\Resources\FailedJobResource\Pages\ViewFailedJobs;
use App\Models\FailedJob;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Novadaemon\FilamentPrettyJson\PrettyJson;
/**
* @source https://gitlab.com/amvisor/filament-failed-jobs
*/
class FailedJobResource extends Resource
{
protected static ?string $model = FailedJob::class;
protected static ?string $navigationIcon = 'heroicon-o-exclamation-circle';
protected static ?string $navigationGroup = 'System';
public static function getNavigationBadge(): ?string
{
return (string) FailedJob::query()->count();
}
public static function form(Form $form): Form
{
return $form
->schema([
TextInput::make('uuid')->disabled()->columnSpan(4),
TextInput::make('failed_at')->disabled(),
TextInput::make('id')->disabled(),
TextInput::make('connection')->disabled(),
TextInput::make('queue')->disabled(),
// make text a little bit smaller because often a complete Stack Trace is shown:
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
PrettyJson::make('payload')->disabled()->columnSpan(4),
])->columns(4);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->columns([
TextColumn::make('id')->sortable()->searchable()->toggleable(),
TextColumn::make('failed_at')->sortable()->searchable(false)->toggleable(),
TextColumn::make('exception')
->sortable()
->searchable()
->toggleable()
->wrap()
->limit(200)
->tooltip(fn (FailedJob $record) => "{$record->failed_at} UUID: {$record->uuid}; Connection: {$record->connection}; Queue: {$record->queue};"),
TextColumn::make('uuid')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('connection')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('queue')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
])
->filters([])
->bulkActions([
BulkAction::make('retry')
->label('Retry')
->requiresConfirmation()
->action(function (Collection $records): void {
/** @var FailedJob $record */
foreach ($records as $record) {
Artisan::call("queue:retry {$record->uuid}");
}
Notification::make()
->title("{$records->count()} jobs have been pushed back onto the queue.")
->success()
->send();
}),
])
->actions([
DeleteAction::make('Delete'),
ViewAction::make('View'),
Action::make('retry')
->label('Retry')
->requiresConfirmation()
->action(function (FailedJob $record): void {
Artisan::call("queue:retry {$record->uuid}");
Notification::make()
->title("The job with uuid '{$record->uuid}' has been pushed back onto the queue.")
->success()
->send();
}),
]);
}
public static function getPages(): array
{
return [
'index' => ListFailedJobs::route('/'),
'view' => ViewFailedJobs::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\FailedJobResource\Pages;
use App\Filament\Resources\FailedJobResource;
use App\Models\FailedJob;
use Filament\Notifications\Notification;
use Filament\Pages\Actions\Action;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;
class ListFailedJobs extends ListRecords
{
protected static string $resource = FailedJobResource::class;
public function getHeaderActions(): array
{
return [
Action::make('retry_all')
->label('Retry all failed Jobs')
->requiresConfirmation()
->action(function (): void {
Artisan::call('queue:retry all');
Notification::make()
->title('All failed jobs have been pushed back onto the queue.')
->success()
->send();
}),
Action::make('delete_all')
->label('Delete all failed Jobs')
->requiresConfirmation()
->color('danger')
->action(function (): void {
FailedJob::truncate();
Notification::make()
->title('All failed jobs have been removed.')
->success()
->send();
}),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\FailedJobResource\Pages;
use App\Filament\Resources\FailedJobResource;
use Filament\Resources\Pages\ViewRecord;
class ViewFailedJobs extends ViewRecord
{
protected static string $resource = FailedJobResource::class;
}

View File

@@ -7,6 +7,7 @@ namespace App\Filament\Resources;
use App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
use App\Models\Organization;
use App\Service\Export\ExportService;
use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto;
@@ -110,6 +111,30 @@ class OrganizationResource extends Resource
])
->actions([
Tables\Actions\EditAction::make(),
Action::make('Export')
->icon('heroicon-o-arrow-down-tray')
->action(function (Organization $record) {
try {
$file = app(ExportService::class)->export($record);
Notification::make()
->title('Export successful')
->success()
->persistent()
->send();
return response()->streamDownload(function () use ($file) {
echo Storage::disk(config('filesystems.private'))->get($file);
}, 'export.zip');
} catch (\Exception $exception) {
report($exception);
Notification::make()
->title('Export failed')
->danger()
->body('Message: '.$exception->getMessage())
->persistent()
->send();
}
}),
Action::make('Import')
->icon('heroicon-o-inbox-arrow-down')
->action(function (Organization $record, array $data) {

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Models\Organization;
use App\Service\Export\ExportException;
use App\Service\Export\ExportService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
class ExportController extends Controller
{
/**
* Export data of an organization
*
* @throws AuthorizationException
* @throws ExportException
*
* @operationId exportOrganization
*/
public function export(Organization $organization, ExportService $exportService): JsonResponse
{
$this->checkPermission($organization, 'export');
$filepath = $exportService->export($organization);
$downloadUrl = Storage::disk(config('filesystems.private'))
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
return new JsonResponse([
'success' => true,
'download_url' => $downloadUrl,
], 200);
}
}

View File

@@ -14,7 +14,6 @@ use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest;
use App\Http\Resources\V1\Member\MemberCollection;
use App\Http\Resources\V1\Member\MemberPivotResource;
use App\Http\Resources\V1\Member\MemberResource;
use App\Models\Member;
use App\Models\Organization;
@@ -40,7 +39,7 @@ class MemberController extends Controller
/**
* List all members of an organization
*
* @return MemberCollection<MemberPivotResource>>
* @return MemberCollection<MemberResource>
*
* @throws AuthorizationException
*
@@ -50,7 +49,9 @@ class MemberController extends Controller
{
$this->checkPermission($organization, 'members:view');
$members = $organization->users()
$members = Member::query()
->whereBelongsTo($organization, 'organization')
->with(['user'])
->paginate(config('app.pagination_per_page_default'));
return MemberCollection::make($members);

View File

@@ -59,7 +59,7 @@ class ProjectMemberController extends Controller
*
* @operationId createProjectMember
*/
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request): JsonResource
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request, BillableRateService $billableRateService): JsonResource
{
$this->checkPermission($organization, 'project-members:create', $project);
@@ -78,6 +78,10 @@ class ProjectMemberController extends Controller
$projectMember->project()->associate($project);
$projectMember->save();
if ($request->getBillableRate() !== null) {
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
}
return new ProjectMemberResource($projectMember);
}
@@ -109,12 +113,22 @@ class ProjectMemberController extends Controller
*
* @operationId deleteProjectMember
*/
public function destroy(Organization $organization, ProjectMember $projectMember): JsonResponse
public function destroy(Organization $organization, ProjectMember $projectMember, BillableRateService $billableRateService): JsonResponse
{
$this->checkPermission($organization, 'project-members:delete', projectMember: $projectMember);
$hadBillableRate = $projectMember->billable_rate !== null;
$project = $projectMember->project;
$member = $projectMember->member;
$projectMember->delete();
if ($hadBillableRate) {
$billableRateService->updateTimeEntriesBillableRateForMember($member);
$billableRateService->updateTimeEntriesBillableRateForProject($project);
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
}
return response()
->json(null, 204);
}

View File

@@ -275,14 +275,14 @@ class TimeEntryController extends Controller
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
$ids = $request->input('ids');
$ids = $request->validated('ids');
$timeEntries = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->whereIn('id', $ids)
->get();
$changes = $request->input('changes');
$changes = $request->validated('changes');
if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) {
throw new AuthorizationException();
@@ -299,6 +299,7 @@ class TimeEntryController extends Controller
$error = new Collection();
foreach ($ids as $id) {
/** @var TimeEntry|null $timeEntry */
$timeEntry = $timeEntries->firstWhere('id', $id);
if ($timeEntry === null) {
// Note: ID wrong or time entry in different organization
@@ -316,6 +317,7 @@ class TimeEntryController extends Controller
if ($overwriteClient) {
$timeEntry->client()->associate($client);
}
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save();
$success->push($id);
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\V1\User\UserResource;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Resources\Json\JsonResource;
class UserController extends Controller
{
/**
* Get the current user
*
* This endpoint is independent of organization.
*
* @operationId getMe
*
* @throws AuthorizationException
*/
public function me(): JsonResource
{
$user = $this->user();
return new UserResource($user);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\V1\Member\PersonalMembershipCollection;
use App\Models\Member;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Resources\Json\JsonResource;
class UserMembershipController extends Controller
{
/**
* Get the memberships of the current user
*
* This endpoint is independent of organization.
*
* @operationId getMyMemberships
*
* @return PersonalMembershipCollection
*
* @throws AuthorizationException
*/
public function myMemberships(): JsonResource
{
$user = $this->user();
$members = Member::query()
->whereBelongsTo($user, 'user')
->with(['organization'])
->get();
return new PersonalMembershipCollection($members);
}
}

View File

@@ -12,5 +12,6 @@ abstract class BaseResource extends JsonResource
protected function formatDateTime(?Carbon $carbon): ?string
{
return $carbon?->toIso8601ZuluString();
}
}

View File

@@ -14,5 +14,5 @@ class MemberCollection extends ResourceCollection implements PaginatedResourceCo
*
* @var string
*/
public $collects = MemberPivotResource::class;
public $collects = MemberResource::class;
}

View File

@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Member;
use App\Http\Resources\V1\BaseResource;
use App\Models\Member;
use App\Models\User;
use Illuminate\Http\Request;
/**
* @property User $resource
*/
class MemberPivotResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string>>
*/
public function toArray(Request $request): array
{
/** @var Member $member */
$member = $this->resource->getRelationValue('membership');
return [
/** @var string $id ID of membership */
'id' => $member->id,
/** @var string $id ID of user */
'user_id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string $email Email */
'email' => $this->resource->email,
/** @var string $role Role */
'role' => $member->role,
/** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
'is_placeholder' => $this->resource->is_placeholder,
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $member->billable_rate,
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Member;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class PersonalMembershipCollection extends ResourceCollection implements PaginatedResourceCollection
{
/**
* The resource that this resource collects.
*
* @var string
*/
public $collects = PersonalMembershipResource::class;
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Member;
use App\Http\Resources\V1\BaseResource;
use App\Models\Member;
use Illuminate\Http\Request;
/**
* @property Member $resource
*/
class PersonalMembershipResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of membership */
'id' => $this->resource->id,
'organization' => [
/** @var string $id ID of organization */
'id' => $this->resource->organization->id,
/** @var string $name Name of organization */
'name' => $this->resource->organization->name,
],
/** @var string $role Role */
'role' => $this->resource->role,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\User;
use App\Enums\Weekday;
use App\Http\Resources\V1\BaseResource;
use App\Models\User;
use Illuminate\Http\Request;
/**
* @property User $resource
*/
class UserResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of user */
'id' => $this->resource->id,
/** @var string $name Name of user */
'name' => $this->resource->name,
/** @var string $email Email of user */
'email' => $this->resource->email,
/** @var string $profile_photo_url Profile photo URL */
'profile_photo_url' => $this->resource->profile_photo_url,
/** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */
'timezone' => $this->resource->timezone,
/** @var Weekday $week_start Starting day of the week */
'week_start' => $this->resource->week_start->value,
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class TimeEntryStillRunningMail extends Mailable
{
use Queueable, SerializesModels;
public TimeEntry $timeEntry;
public User $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(TimeEntry $timeEntry, User $user)
{
$this->timeEntry = $timeEntry;
$this->user = $user;
}
/**
* Build the message.
*/
public function build(): self
{
return $this->markdown('emails.time-entry-still-running', [
'dashboardUrl' => URL::route('dashboard'),
])
->subject(__('Your Time Tracker is still running!'));
}
}

33
app/Models/Audit.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\AuditFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Models\Audit as PackageAuditModel;
/**
* @property int $id
* @property string|null $user_type
* @property string|null $user_id
* @property string $event
* @property string $auditable_type
* @property string $auditable_id
* @property array|null $old_values
* @property array|null $new_values
* @property string|null $url
* @property string|null $ip_address
* @property string|null $user_agent
* @property string|null $tags
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @method static AuditFactory factory()
*/
class Audit extends PackageAuditModel
{
use HasFactory;
}

View File

@@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
* @property string $id
@@ -25,8 +27,9 @@ use Illuminate\Support\Carbon;
*
* @method static ClientFactory factory()
*/
class Client extends Model
class Client extends Model implements AuditableContract
{
use Auditable;
use HasFactory;
use HasUuids;

37
app/Models/FailedJob.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property string $uuid
* @property string $connection
* @property string $queue
* @property Carbon $failed_at
*/
class FailedJob extends Model
{
use HasFactory;
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = false;
/**
* The attributes that should be cast to native types.
*
* @var array<string, string>
*/
protected $casts = [
'failed_at' => 'datetime',
'payload' => 'json',
];
}

View File

@@ -9,7 +9,10 @@ use Database\Factories\MemberFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Laravel\Jetstream\Membership as JetstreamMembership;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
* @property string $id
@@ -17,15 +20,16 @@ use Laravel\Jetstream\Membership as JetstreamMembership;
* @property int|null $billable_rate
* @property string $organization_id
* @property string $user_id
* @property string $created_at
* @property string $updated_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Organization $organization
* @property-read User $user
*
* @method static MemberFactory factory()
*/
class Member extends JetstreamMembership
class Member extends JetstreamMembership implements AuditableContract
{
use Auditable;
use HasFactory;
use HasUuids;

View File

@@ -18,6 +18,8 @@ use Laravel\Jetstream\Events\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Team as JetstreamTeam;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
* @property string $id
@@ -37,8 +39,9 @@ use Laravel\Jetstream\Team as JetstreamTeam;
* @method HasMany<OrganizationInvitation> teamInvitations()
* @method static OrganizationFactory factory()
*/
class Organization extends JetstreamTeam
class Organization extends JetstreamTeam implements AuditableContract
{
use Auditable;
use HasFactory;
use HasUuids;

View File

@@ -8,20 +8,26 @@ use App\Models\Concerns\HasUuids;
use Database\Factories\OrganizationInvitationFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
* @property string $id
* @property string $email
* @property string $role
* @property string $organization_id
* @property Carbon|null $updated_at
* @property Carbon|null $created_at
* @property-read Organization $organization
*
* @method static OrganizationInvitationFactory factory()
*/
class OrganizationInvitation extends JetstreamTeamInvitation
class OrganizationInvitation extends JetstreamTeamInvitation implements AuditableContract
{
use Auditable;
use HasFactory;
use HasUuids;

View File

@@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
* @property string $id
@@ -22,6 +24,7 @@ use Illuminate\Support\Carbon;
* @property string $organization_id
* @property string $client_id
* @property int|null $billable_rate
* @property bool $is_public
* @property bool $is_billable
* @property-read bool $is_archived
* @property Carbon|null $archived_at
@@ -35,8 +38,9 @@ use Illuminate\Support\Carbon;
* @method Builder<Project> visibleByEmployee(User $user)
* @method static ProjectFactory factory()
*/
class Project extends Model
class Project extends Model implements AuditableContract
{
use Auditable;
use HasFactory;
use HasUuids;

View File

@@ -10,6 +10,9 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
* @property string $id
@@ -17,6 +20,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $project_id Project ID
* @property string $member_id Member ID
* @property string $user_id User ID (legacy)
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Project $project
* @property-read Member $member
* @property-read User $user
@@ -24,8 +29,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
* @method static ProjectMemberFactory factory()
*/
class ProjectMember extends Model
class ProjectMember extends Model implements AuditableContract
{
use Auditable;
use HasFactory;
use HasUuids;

View File

@@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
* @property string $id
@@ -21,8 +23,9 @@ use Illuminate\Support\Carbon;
*
* @method static TagFactory factory()
*/
class Tag extends Model
class Tag extends Model implements AuditableContract
{
use Auditable;
use HasFactory;
use HasUuids;

View File

@@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
* @property string $id
@@ -30,8 +32,9 @@ use Illuminate\Support\Carbon;
*
* @method static TaskFactory factory()
*/
class Task extends Model
class Task extends Model implements AuditableContract
{
use Auditable;
use HasFactory;
use HasUuids;
@@ -42,6 +45,7 @@ class Task extends Model
*/
protected $casts = [
'name' => 'string',
'done_at' => 'datetime',
];
/**

View File

@@ -14,18 +14,23 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Korridor\LaravelComputedAttributes\ComputedAttributes;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
* @property string $id
* @property string $description
* @property Carbon $start
* @property Carbon|null $end
* @property int $billable_rate Billable rate per hour in cents
* @property int|null $billable_rate Billable rate per hour in cents
* @property bool $billable
* @property array $tags
* @property string $user_id
* @property string $member_id
* @property bool $is_imported
* @property Carbon|null $still_active_email_sent_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read User $user
* @property-read Member $member
* @property string $organization_id
@@ -40,8 +45,9 @@ use Korridor\LaravelComputedAttributes\ComputedAttributes;
* @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory()
*/
class TimeEntry extends Model
class TimeEntry extends Model implements AuditableContract
{
use Auditable;
use ComputedAttributes;
use HasFactory;
use HasUuids;
@@ -59,6 +65,7 @@ class TimeEntry extends Model
'tags' => 'array',
'billable_rate' => 'int',
'is_imported' => 'bool',
'still_active_email_sent_at' => 'datetime',
];
/**

View File

@@ -25,6 +25,8 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams;
use Laravel\Passport\HasApiTokens;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
* @property string $id
@@ -53,8 +55,9 @@ use Laravel\Passport\HasApiTokens;
* @method Builder<User> belongsToOrganization(Organization $organization)
* @method Builder<User> active()
*/
class User extends Authenticatable implements FilamentUser, MustVerifyEmail
class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail
{
use Auditable;
use HasApiTokens;
use HasFactory;
use HasProfilePhoto;

View File

@@ -5,10 +5,12 @@ declare(strict_types=1);
namespace App\Providers;
use App\Models\Client;
use App\Models\FailedJob;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
@@ -22,6 +24,7 @@ use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
use Dedoc\Scramble\Support\Generator\SecuritySchemes\OAuthFlow;
use Filament\Forms\Components\Section;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application;
@@ -50,25 +53,34 @@ class AppServiceProvider extends ServiceProvider
$this->app->register(TelescopeServiceProvider::class);
}
// Eloquent
Model::preventLazyLoading(! $this->app->isProduction());
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
Model::preventAccessingMissingAttributes(! $this->app->isProduction());
Relation::enforceMorphMap([
'client' => Client::class,
'failed-job' => FailedJob::class,
'membership' => Member::class,
'organization' => Organization::class,
'organization-invitation' => OrganizationInvitation::class,
'user' => User::class,
'time-entry' => TimeEntry::class,
'project' => Project::class,
'task' => Task::class,
'client' => Client::class,
'project-member' => ProjectMember::class,
'tag' => Tag::class,
'task' => Task::class,
'time-entry' => TimeEntry::class,
'user' => User::class,
]);
Model::unguard();
// Filament
Section::configureUsing(function (Section $section): void {
$section->columns(1);
}, null, true);
Table::configureUsing(function (Table $table): void {
$table->paginated([10, 25, 50, 100]);
});
// Scramble
Scramble::extendOpenApi(function (OpenApi $openApi) {
$openApi->secure(
SecurityScheme::oauth2()
@@ -92,6 +104,7 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
$this->app->bind(BillingContract::class);
// Routing
Route::model('member', Member::class);
Route::model('invitation', OrganizationInvitation::class);
}

View File

@@ -63,6 +63,9 @@ class AdminPanelProvider extends PanelProvider
NavigationGroup::make()
->label('Users')
->collapsed(),
NavigationGroup::make()
->label('System')
->collapsed(),
])
->middleware([
EncryptCookies::class,

View File

@@ -114,6 +114,7 @@ class JetstreamServiceProvider extends ServiceProvider
'organizations:update',
'organizations:delete',
'import',
'export',
'invitations:view',
'invitations:create',
'invitations:resend',
@@ -159,6 +160,7 @@ class JetstreamServiceProvider extends ServiceProvider
'organizations:view',
'organizations:update',
'import',
'export',
'invitations:view',
'invitations:create',
'invitations:resend',

View File

@@ -86,7 +86,13 @@ class DeletionService
'currentOrganization',
])
->get();
$organization->users()->sync([]);
$members = Member::query()
->whereBelongsTo($organization, 'organization')
->get();
foreach ($members as $member) {
$member->delete();
}
// Make sure all users have at least one organization and delete placeholders
foreach ($users as $user) {

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Service\Export;
use App\Exceptions\Api\ApiException;
class ExportException extends ApiException
{
public const string KEY = 'export';
}

View File

@@ -0,0 +1,362 @@
<?php
declare(strict_types=1);
namespace App\Service\Export;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use Exception;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\File;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use League\Csv\CannotInsertRecord;
use League\Csv\Exception as LeagueCsvException;
use League\Csv\UnavailableStream;
use League\Csv\Writer;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ZipArchive;
class ExportService
{
public const string VERSION = '1.0';
/**
* @throws ExportException
*/
public function export(Organization $organization): string
{
$exportId = Str::uuid();
$timeStamp = Carbon::now();
$temporaryDirectory = TemporaryDirectory::make();
Log::debug('Start exporting organization', [
'organization_id' => $organization->getKey(),
'export_id' => $exportId,
]);
// Organizations
try {
$writer = Writer::createFromPath($temporaryDirectory->path('organizations.csv'), 'w+');
$writer->insertOne([
'id',
'name',
'billable_rate',
'currency',
'created_at',
'updated_at',
]);
$writer->insertOne([
$organization->id,
$organization->name,
$organization->billable_rate ?? '',
$organization->currency,
$organization->created_at?->toIso8601ZuluString() ?? '',
$organization->updated_at?->toIso8601ZuluString() ?? '',
]);
// Organization invitations
$writer = Writer::createFromPath($temporaryDirectory->path('organization_invitations.csv'), 'w+');
$writer->insertOne([
'id',
'email',
'organization_id',
'role',
'created_at',
'updated_at',
]);
OrganizationInvitation::query()
->whereBelongsTo($organization, 'organization')
->chunk(1000, function (Collection $organizationInvitations) use (&$writer): void {
$organizationInvitations->each(function (OrganizationInvitation $organizationInvitation) use (&$writer): void {
$writer->insertOne([
$organizationInvitation->id,
$organizationInvitation->email,
$organizationInvitation->organization_id,
$organizationInvitation->role,
$organizationInvitation->created_at?->toIso8601ZuluString() ?? '',
$organizationInvitation->updated_at?->toIso8601ZuluString() ?? '',
]);
});
});
// Time entries
$writer = Writer::createFromPath($temporaryDirectory->path('time_entries.csv'), 'w+');
$writer->insertOne([
'id',
'description',
'start',
'end',
'billable_rate',
'billable',
'member_id',
'user_id',
'organization_id',
'client_id',
'project_id',
'task_id',
'tags',
'is_imported',
'still_active_email_sent_at',
'created_at',
'updated_at',
]);
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->chunk(1000, function (Collection $timeEntries) use (&$writer): void {
$timeEntries->each(function (TimeEntry $timeEntry) use (&$writer): void {
$tags = json_encode($timeEntry->tags);
$writer->insertOne([
$timeEntry->id,
$timeEntry->description,
$timeEntry->start->toIso8601ZuluString(),
$timeEntry->end?->toIso8601ZuluString() ?? '',
$timeEntry->billable_rate ?? '',
$timeEntry->billable ? 'true' : 'false',
$timeEntry->member_id,
$timeEntry->user_id,
$timeEntry->organization_id,
$timeEntry->client_id ?? '',
$timeEntry->project_id ?? '',
$timeEntry->task_id ?? '',
$tags === false ? '' : $tags,
$timeEntry->is_imported ? 'true' : 'false',
$timeEntry->still_active_email_sent_at?->toIso8601ZuluString() ?? '',
$timeEntry->created_at?->toIso8601ZuluString() ?? '',
$timeEntry->updated_at?->toIso8601ZuluString() ?? '',
]);
});
});
// Clients
$writer = Writer::createFromPath($temporaryDirectory->path('clients.csv'), 'w+');
$writer->insertOne([
'id',
'name',
'organization_id',
'archived_at',
'created_at',
'updated_at',
]);
Client::query()
->whereBelongsTo($organization, 'organization')
->chunk(1000, function (Collection $clients) use (&$writer): void {
$clients->each(function (Client $client) use (&$writer): void {
$writer->insertOne([
$client->id,
$client->name,
$client->organization_id,
$client->archived_at ?? '',
$client->created_at?->toIso8601ZuluString() ?? '',
$client->updated_at?->toIso8601ZuluString() ?? '',
]);
});
});
// Projects
$writer = Writer::createFromPath($temporaryDirectory->path('projects.csv'), 'w+');
$writer->insertOne([
'id',
'name',
'color',
'billable_rate',
'is_public',
'client_id',
'organization_id',
'is_billable',
'archived_at',
'created_at',
'updated_at',
]);
Project::query()
->whereBelongsTo($organization, 'organization')
->chunk(1000, function (Collection $projects) use (&$writer): void {
$projects->each(function (Project $project) use (&$writer): void {
$writer->insertOne([
$project->id,
$project->name,
$project->color,
$project->billable_rate ?? '',
$project->is_public ? 'true' : 'false',
$project->client_id ?? '',
$project->organization_id,
$project->is_billable ? 'true' : 'false',
$project->archived_at?->toIso8601ZuluString() ?? '',
$project->created_at?->toIso8601ZuluString() ?? '',
$project->updated_at?->toIso8601ZuluString() ?? '',
]);
});
});
// Project members
$writer = Writer::createFromPath($temporaryDirectory->path('project_members.csv'), 'w+');
$writer->insertOne([
'id',
'billable_rate',
'project_id',
'user_id',
'member_id',
'created_at',
'updated_at',
]);
ProjectMember::query()
->whereBelongsToOrganization($organization)
->chunk(1000, function (Collection $projectMembers) use (&$writer): void {
$projectMembers->each(function (ProjectMember $projectMember) use (&$writer): void {
$writer->insertOne([
$projectMember->id,
$projectMember->billable_rate ?? '',
$projectMember->project_id,
$projectMember->user_id,
$projectMember->member_id,
$projectMember->created_at?->toIso8601ZuluString() ?? '',
$projectMember->updated_at?->toIso8601ZuluString() ?? '',
]);
});
});
// Members
$writer = Writer::createFromPath($temporaryDirectory->path('members.csv'), 'w+');
$writer->insertOne([
'id',
'user_id',
'name',
'email',
'organization_id',
'billable_rate',
'role',
'created_at',
'updated_at',
]);
Member::query()
->whereBelongsTo($organization, 'organization')
->with([
'user',
])
->chunk(1000, function (Collection $members) use (&$writer): void {
$members->each(function (Member $member) use (&$writer): void {
$writer->insertOne([
$member->id,
$member->user_id,
$member->user->name,
$member->user->email,
$member->organization_id,
$member->billable_rate ?? '',
$member->role,
$member->created_at?->toIso8601ZuluString() ?? '',
$member->updated_at?->toIso8601ZuluString() ?? '',
]);
});
});
// Tasks
$writer = Writer::createFromPath($temporaryDirectory->path('tasks.csv'), 'w+');
$writer->insertOne([
'id',
'name',
'project_id',
'organization_id',
'done_at',
'created_at',
'updated_at',
]);
Task::query()
->whereBelongsTo($organization, 'organization')
->chunk(1000, function (Collection $tasks) use (&$writer): void {
$tasks->each(function (Task $task) use (&$writer): void {
$writer->insertOne([
$task->id,
$task->name,
$task->project_id,
$task->organization_id,
$task->done_at?->toIso8601ZuluString() ?? '',
$task->created_at?->toIso8601ZuluString() ?? '',
$task->updated_at?->toIso8601ZuluString() ?? '',
]);
});
});
// Tags
$writer = Writer::createFromPath($temporaryDirectory->path('tags.csv'), 'w+');
$writer->insertOne([
'id',
'name',
'organization_id',
'created_at',
'updated_at',
]);
Tag::query()
->whereBelongsTo($organization, 'organization')
->chunk(1000, function (Collection $tags) use (&$writer): void {
$tags->each(function (Tag $tag) use (&$writer): void {
$writer->insertOne([
$tag->id,
$tag->name,
$tag->organization_id,
$tag->created_at?->toIso8601ZuluString() ?? '',
$tag->updated_at?->toIso8601ZuluString() ?? '',
]);
});
});
// Meta data file
$metaData = (object) [
'id' => $exportId,
'version' => self::VERSION,
'organizations' => [$organization->getKey()],
'exported_at' => $timeStamp->toIso8601ZuluString(),
];
file_put_contents($temporaryDirectory->path('meta.json'), json_encode($metaData));
// Create ZIP file
$temporaryDirectoryZip = TemporaryDirectory::make();
$zip = new ZipArchive();
if ($zip->open($temporaryDirectoryZip->path('export.zip'), ZipArchive::CREATE) !== true) {
throw new Exception('Cannot create ZIP file');
}
$zip->addFile($temporaryDirectory->path('organizations.csv'), 'organizations.csv');
$zip->addFile($temporaryDirectory->path('organization_invitations.csv'), 'organization_invitations.csv');
$zip->addFile($temporaryDirectory->path('time_entries.csv'), 'time_entries.csv');
$zip->addFile($temporaryDirectory->path('clients.csv'), 'clients.csv');
$zip->addFile($temporaryDirectory->path('projects.csv'), 'projects.csv');
$zip->addFile($temporaryDirectory->path('project_members.csv'), 'project_members.csv');
$zip->addFile($temporaryDirectory->path('members.csv'), 'members.csv');
$zip->addFile($temporaryDirectory->path('tasks.csv'), 'tasks.csv');
$zip->addFile($temporaryDirectory->path('tags.csv'), 'tags.csv');
$zip->addFile($temporaryDirectory->path('meta.json'), 'meta.json');
$zip->close();
// Upload ZIP file to private storage
$filename = 'export_'.$organization->getKey().'_'.$timeStamp->format('Y-m-d_H-i-s').'_'.$exportId.'.zip';
Storage::disk(config('filesystems.private'))->putFileAs(
'exports',
new File($temporaryDirectoryZip->path('export.zip')),
$filename
);
// Delete temp files
$temporaryDirectoryZip->delete();
$temporaryDirectory->delete();
Log::debug('Finished exporting organization', [
'organization_id' => $organization->getKey(),
'export_id' => $exportId,
]);
return 'exports/'.$filename;
} catch (UnavailableStream|CannotInsertRecord|Exception|LeagueCsvException $exception) {
report($exception);
throw new ExportException();
}
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Service\Import\Importers;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Tag;
@@ -63,6 +64,11 @@ abstract class DefaultImporter implements ImporterContract
*/
protected ImportDatabaseHelper $projectMemberImportHelper;
/**
* @var ImportDatabaseHelper<OrganizationInvitation>
*/
protected ImportDatabaseHelper $organizationInvitationsImportHelper;
protected BillableRateService $billableRateService;
public function init(Organization $organization): void
@@ -149,6 +155,15 @@ abstract class DefaultImporter implements ImporterContract
'max:500',
],
]);
$this->organizationInvitationsImportHelper = new ImportDatabaseHelper(OrganizationInvitation::class, ['email', 'organization_id'], true, function (Builder $builder) {
return $builder->where('organization_id', $this->organization->id);
}, validate: [
'email' => [
'required',
'email',
'max:255',
],
]);
$this->timeEntriesCreated = 0;
$this->colorService = app(ColorService::class);
$this->timezoneService = app(TimezoneService::class);

View File

@@ -14,6 +14,7 @@ class ImporterProvider
'toggl_data_importer' => TogglDataImporter::class,
'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
'clockify_projects' => ClockifyProjectsImporter::class,
'solidtime' => SolidtimeImporter::class,
];
/**

View File

@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Reader;
use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ZipArchive;
class SolidtimeImporter extends DefaultImporter
{
/**
* @var array<string>
*/
public const array SUPPORTED_VERSIONS = ['1.0'];
/**
* @throws ImportException
*/
#[Override]
public function importData(string $data, string $timezone): void
{
$temporaryDirectoryZip = null;
$temporaryDirectory = null;
try {
$zip = new ZipArchive();
$temporaryDirectoryZip = TemporaryDirectory::make();
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
if ($res !== true) {
throw new ImportException('Invalid ZIP, error code: '.$res);
}
$temporaryDirectory = TemporaryDirectory::make();
$zip->extractTo($temporaryDirectory->path());
$zip->close();
if (! file_exists($temporaryDirectory->path('meta.json'))) {
throw new ImportException('File "meta.json" missing in ZIP');
}
$metaFileContentRaw = file_get_contents($temporaryDirectory->path('meta.json'));
if ($metaFileContentRaw === false) {
throw new ImportException('File "meta.json" can not read');
}
$metaFileContent = json_decode($metaFileContentRaw);
if ($metaFileContent === false || ! isset($metaFileContent->version) || ! in_array($metaFileContent->version, self::SUPPORTED_VERSIONS, true)) {
throw new ImportException('Invalid version');
}
if (! file_exists($temporaryDirectory->path('clients.csv'))) {
throw new ImportException('File "clients.csv" missing in ZIP');
}
$clientsReader = Reader::createFromPath($temporaryDirectory->path('clients.csv'));
$clientsReader->setHeaderOffset(0);
$clientsReader->setDelimiter(',');
if (! file_exists($temporaryDirectory->path('members.csv'))) {
throw new ImportException('File "members.csv" missing in ZIP');
}
$membersReader = Reader::createFromPath($temporaryDirectory->path('members.csv'));
$membersReader->setHeaderOffset(0);
$membersReader->setDelimiter(',');
if (! file_exists($temporaryDirectory->path('organization_invitations.csv'))) {
throw new ImportException('File "organization_invitations.csv" missing in ZIP');
}
$organizationInvitationsReader = Reader::createFromPath($temporaryDirectory->path('organization_invitations.csv'));
$organizationInvitationsReader->setHeaderOffset(0);
$organizationInvitationsReader->setDelimiter(',');
if (! file_exists($temporaryDirectory->path('project_members.csv'))) {
throw new ImportException('File "project_members.csv" missing in ZIP');
}
$projectMembersReader = Reader::createFromPath($temporaryDirectory->path('project_members.csv'));
$projectMembersReader->setHeaderOffset(0);
$projectMembersReader->setDelimiter(',');
if (! file_exists($temporaryDirectory->path('projects.csv'))) {
throw new ImportException('File "projects.csv" missing in ZIP');
}
$projectsReader = Reader::createFromPath($temporaryDirectory->path('projects.csv'));
$projectsReader->setHeaderOffset(0);
$projectsReader->setDelimiter(',');
if (! file_exists($temporaryDirectory->path('tags.csv'))) {
throw new ImportException('File "tags.csv" missing in ZIP');
}
$tagsReader = Reader::createFromPath($temporaryDirectory->path('tags.csv'));
$tagsReader->setHeaderOffset(0);
$tagsReader->setDelimiter(',');
if (! file_exists($temporaryDirectory->path('tasks.csv'))) {
throw new ImportException('File "tasks.csv" missing in ZIP');
}
$tasksReader = Reader::createFromPath($temporaryDirectory->path('tasks.csv'));
$tasksReader->setHeaderOffset(0);
$tasksReader->setDelimiter(',');
if (! file_exists($temporaryDirectory->path('time_entries.csv'))) {
throw new ImportException('File "time_entries.csv" missing in ZIP');
}
$timeEntriesReader = Reader::createFromPath($temporaryDirectory->path('time_entries.csv'));
$timeEntriesReader->setHeaderOffset(0);
$timeEntriesReader->setDelimiter(',');
foreach ($clientsReader as $client) {
$this->clientImportHelper->getKey([
'name' => $client['name'],
'organization_id' => $this->organization->id,
], [
'archived_at' => $client['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $client['archived_at'], 'UTC') : null,
], $client['id']);
}
foreach ($tagsReader as $tag) {
$this->tagImportHelper->getKey([
'name' => $tag['name'],
'organization_id' => $this->organization->id,
], [], $tag['id']);
}
foreach ($membersReader as $member) {
$userId = $this->userImportHelper->getKey([
'email' => $member['email'],
], [
'name' => $member['name'],
'timezone' => 'UTC',
'is_placeholder' => true,
], $member['user_id']);
$this->memberImportHelper->getKey([
'user_id' => $userId,
'organization_id' => $this->organization->getKey(),
], [
'role' => Role::Placeholder->value,
'billable_rate' => $member['billable_rate'] === '' ? null : (int) $member['billable_rate'],
], $member['id']);
}
foreach ($projectsReader as $project) {
$clientId = null;
if ($project['client_id'] !== '') {
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier($project['client_id']);
if ($clientId === null) {
throw new Exception('Client does not exist');
}
}
if (! $this->colorService->isValid($project['color'])) {
throw new ImportException('Invalid color');
}
$this->projectImportHelper->getKey([
'name' => $project['name'],
'organization_id' => $this->organization->getKey(),
], [
'color' => $project['color'],
'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'],
'is_public' => $project['is_public'] === 'true',
'client_id' => $clientId,
'is_billable' => $project['is_billable'] === 'true',
'archived_at' => $project['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $project['archived_at'], 'UTC') : null,
], $project['id']);
}
foreach ($projectMembersReader as $projectMember) {
$userId = $this->userImportHelper->getKeyByExternalIdentifier($projectMember['user_id']);
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($projectMember['member_id']);
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier($projectMember['project_id']);
$this->projectMemberImportHelper->getKey([
'project_id' => $projectId,
'member_id' => $memberId,
], [
'user_id' => $userId,
'billable_rate' => $projectMember['billable_rate'] === '' ? null : (int) $projectMember['billable_rate'],
], $projectMember['id']);
}
foreach ($tasksReader as $task) {
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier($task['project_id']);
if ($projectId === null) {
throw new Exception('Project does not exist');
}
$this->taskImportHelper->getKey([
'name' => $task['name'],
'project_id' => $projectId,
'organization_id' => $this->organization->getKey(),
], [
'done_at' => $task['done_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $task['done_at'], 'UTC') : null,
], (string) $task['id']);
}
// Time entries
foreach ($timeEntriesReader as $timeEntryRow) {
$userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']);
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']);
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if ($timeEntryRow['client_id'] !== '') {
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier($timeEntryRow['client_id']);
}
$project = null;
$projectId = null;
$projectMember = null;
if ($timeEntryRow['project_id'] !== '') {
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier($timeEntryRow['project_id']);
$project = $this->projectImportHelper->getModelById($projectId);
$projectMember = $this->projectMemberImportHelper->getModel([
'project_id' => $projectId,
'member_id' => $memberId,
]);
}
$taskId = null;
if ($timeEntryRow['task_id'] !== '') {
$taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']);
}
$timeEntry = new TimeEntry();
$timeEntry->user_id = $userId;
$timeEntry->member_id = $memberId;
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
if (strlen($timeEntryRow['description']) > 500) {
throw new ImportException('Time entry description is too long');
}
$timeEntry->description = $timeEntryRow['description'];
if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $timeEntryRow['billable'] === 'true';
$timeEntry->tags = $this->getTags($timeEntryRow['tags']);
$timeEntry->is_imported = true;
try {
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['start'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('Start date ("'.$timeEntryRow['start'].'") is invalid');
}
if ($start === null) {
throw new ImportException('Start date ("'.$timeEntryRow['start'].'") is invalid');
}
$timeEntry->start = $start->utc();
if ($timeEntryRow['end'] !== '') {
try {
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
}
if ($end === null) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
}
$timeEntry->end = $end->utc();
} else {
$timeEntry->end = null;
}
if ($timeEntryRow['still_active_email_sent_at'] !== '') {
try {
$stillActiveEmailSentAt = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['still_active_email_sent_at'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('Still active email timestamp ("'.$timeEntryRow['still_active_email_sent_at'].'") is invalid');
}
if ($stillActiveEmailSentAt === null) {
throw new ImportException('Still active email timestamp ("'.$timeEntryRow['still_active_email_sent_at'].'") is invalid');
}
$timeEntry->still_active_email_sent_at = $stillActiveEmailSentAt->utc();
} else {
$timeEntry->still_active_email_sent_at = null;
}
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,
$project,
$member,
$this->organization
);
$timeEntry->save();
$this->timeEntriesCreated++;
}
} catch (ImportException $exception) {
throw $exception;
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
} finally {
$temporaryDirectory?->delete();
$temporaryDirectoryZip?->delete();
}
}
/**
* @return array<string>
*/
private function getTags(string $tags): array
{
if (trim($tags) === '') {
return [];
}
$tagsParsed = json_decode($tags);
if ($tagsParsed === false || ! is_array($tagsParsed)) {
return [];
}
$tagIds = [];
foreach ($tagsParsed as $tagParsed) {
if (! is_string($tagParsed) || ! Str::isUuid($tagParsed)) {
continue;
}
$tagId = $this->tagImportHelper->getKeyByExternalIdentifier($tagParsed);
$tagIds[] = $tagId;
}
return $tagIds;
}
#[Override]
public function getName(): string
{
return __('importer.solidtime_importer.name');
}
#[Override]
public function getDescription(): string
{
return __('importer.solidtime_importer.description');
}
}

View File

@@ -6,6 +6,8 @@ namespace App\Service\Import\Importers;
use App\Enums\Role;
use Exception;
use Illuminate\Support\Carbon;
use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ValueError;
use ZipArchive;
@@ -15,14 +17,16 @@ class TogglDataImporter extends DefaultImporter
/**
* @throws ImportException
*/
#[\Override]
#[Override]
public function importData(string $data, string $timezone): void
{
$temporaryDirectoryZip = null;
$temporaryDirectory = null;
try {
$zip = new ZipArchive();
$temporaryDirectory = TemporaryDirectory::make();
file_put_contents($temporaryDirectory->path('import.zip'), $data);
$res = $zip->open($temporaryDirectory->path('import.zip'), ZipArchive::RDONLY);
$temporaryDirectoryZip = TemporaryDirectory::make();
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
if ($res !== true) {
throw new ImportException('Invalid ZIP, error code: '.$res);
}
@@ -77,7 +81,9 @@ class TogglDataImporter extends DefaultImporter
$this->clientImportHelper->getKey([
'name' => $client->name,
'organization_id' => $this->organization->id,
], [], (string) $client->id);
], [
'archived_at' => $client->archived === true ? Carbon::now() : null,
], (string) $client->id);
}
foreach ($tags as $tag) {
$this->tagImportHelper->getKey([
@@ -121,7 +127,8 @@ class TogglDataImporter extends DefaultImporter
], [
'client_id' => $clientId,
'color' => $project->color,
'is_billable' => $project->rate !== null,
'is_billable' => $project->billable,
'is_public' => ! $project->is_private,
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
], (string) $project->id);
@@ -170,7 +177,9 @@ class TogglDataImporter extends DefaultImporter
'name' => $task->name,
'project_id' => $projectId,
'organization_id' => $this->organization->getKey(),
], [], (string) $task->id);
], [
'done_at' => $task->active === false ? Carbon::now() : null,
], (string) $task->id);
}
}
} catch (ValueError $exception) {
@@ -180,16 +189,19 @@ class TogglDataImporter extends DefaultImporter
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
} finally {
$temporaryDirectory?->delete();
$temporaryDirectoryZip?->delete();
}
}
#[\Override]
#[Override]
public function getName(): string
{
return __('importer.toggl_data_importer.name');
}
#[\Override]
#[Override]
public function getDescription(): string
{
return __('importer.toggl_data_importer.description');

View File

@@ -65,6 +65,12 @@ class TimeEntryAggregationService
if ($groupBy !== null) {
$timeEntriesQuery->groupBy($groupBy);
}
if ($group1Select !== null) {
$timeEntriesQuery->orderBy('group_1');
if ($group2Select !== null) {
$timeEntriesQuery->orderBy('group_2');
}
}
$timeEntriesAggregates = $timeEntriesQuery->get();

View File

@@ -22,7 +22,9 @@
"laravel/passport": "^12.0",
"laravel/tinker": "^2.8",
"league/flysystem-aws-s3-v3": "^3.0",
"novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^11.0.11",
"owen-it/laravel-auditing": "^13.6",
"pxlrbt/filament-environment-indicator": "^2.0",
"spatie/temporary-directory": "^2.2",
"stechstudio/filament-impersonate": "^3.8",

164
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "cd6f3efa43ee6a6576c68bdb0b69a19e",
"content-hash": "38a45676e6bb2159275c370648f7ca11",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -1403,12 +1403,12 @@
"source": {
"type": "git",
"url": "https://github.com/korridor/scramble.git",
"reference": "78b2a5a5eb8071b006bddc95e34e0620c8bf3b7d"
"reference": "ff692e60e3827ee395007d19ae377fc0d7274fe3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/korridor/scramble/zipball/78b2a5a5eb8071b006bddc95e34e0620c8bf3b7d",
"reference": "78b2a5a5eb8071b006bddc95e34e0620c8bf3b7d",
"url": "https://api.github.com/repos/korridor/scramble/zipball/ff692e60e3827ee395007d19ae377fc0d7274fe3",
"reference": "ff692e60e3827ee395007d19ae377fc0d7274fe3",
"shasum": ""
},
"require": {
@@ -1487,7 +1487,7 @@
"url": "https://github.com/romalytvynenko"
}
],
"time": "2024-03-19T15:56:18+00:00"
"time": "2024-07-17T11:33:15+00:00"
},
{
"name": "defuse/php-encryption",
@@ -6287,6 +6287,72 @@
},
"time": "2024-03-05T20:51:40+00:00"
},
{
"name": "novadaemon/filament-pretty-json",
"version": "v2.2.2",
"source": {
"type": "git",
"url": "https://github.com/novadaemon/filament-pretty-json.git",
"reference": "b295957f240cf848ffd402d842e9db6e7b5346f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/novadaemon/filament-pretty-json/zipball/b295957f240cf848ffd402d842e9db6e7b5346f3",
"reference": "b295957f240cf848ffd402d842e9db6e7b5346f3",
"shasum": ""
},
"require": {
"filament/filament": "^3.0",
"filament/forms": "^3.0",
"illuminate/contracts": "^10.0|^11.0",
"php": "^8.1|^8.2",
"spatie/laravel-package-tools": "^1.15.0"
},
"require-dev": {
"laravel/pint": "^1.0",
"nunomaduro/collision": "^7.9|^8.1",
"phpstan/extension-installer": "^1.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Novadaemon\\FilamentPrettyJson\\FilamentPrettyJsonServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Novadaemon\\FilamentPrettyJson\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jesús García",
"email": "novadaemon@gmail.com"
}
],
"description": "Read-only field to show pretty json in your filamentphp forms",
"homepage": "https://github.com/novadaemon/filament-pretty-json",
"keywords": [
"Forms",
"field",
"filament",
"filament-pretty-json",
"json",
"laravel",
"novadaemon"
],
"support": {
"issues": "https://github.com/novadaemon/filament-pretty-json/issues",
"source": "https://github.com/novadaemon/filament-pretty-json"
},
"time": "2024-06-08T20:19:44+00:00"
},
{
"name": "nunomaduro/termwind",
"version": "v2.0.1",
@@ -6633,6 +6699,94 @@
],
"time": "2024-06-17T08:53:37+00:00"
},
{
"name": "owen-it/laravel-auditing",
"version": "v13.6.8",
"source": {
"type": "git",
"url": "https://github.com/owen-it/laravel-auditing.git",
"reference": "28ecd2d5cc05c3619f99af42611877f54371af20"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/owen-it/laravel-auditing/zipball/28ecd2d5cc05c3619f99af42611877f54371af20",
"reference": "28ecd2d5cc05c3619f99af42611877f54371af20",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^7.0|^8.0|^9.0|^10.0|^11.0",
"illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0",
"illuminate/filesystem": "^7.0|^8.0|^9.0|^10.0|^11.0",
"php": "^7.3|^8.0"
},
"require-dev": {
"laravel/legacy-factories": "*",
"mockery/mockery": "^1.0",
"orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0",
"phpunit/phpunit": "^9.6|^10.5|^11.0"
},
"suggest": {
"irazasyed/larasupport": "Needed to publish the package configuration in Lumen"
},
"type": "package",
"extra": {
"branch-alias": {
"dev-master": "v13-dev"
},
"laravel": {
"providers": [
"OwenIt\\Auditing\\AuditingServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"OwenIt\\Auditing\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antério Vieira",
"email": "anteriovieira@gmail.com"
},
{
"name": "Raphael França",
"email": "raphaelfrancabsb@gmail.com"
},
{
"name": "Morten D. Hansen",
"email": "morten@visia.dk"
}
],
"description": "Audit changes of your Eloquent models in Laravel/Lumen",
"homepage": "https://laravel-auditing.com",
"keywords": [
"Accountability",
"Audit",
"auditing",
"changes",
"eloquent",
"history",
"laravel",
"log",
"logging",
"lumen",
"observer",
"record",
"revision",
"tracking"
],
"support": {
"issues": "https://github.com/owen-it/laravel-auditing/issues",
"source": "https://github.com/owen-it/laravel-auditing"
},
"time": "2024-06-26T20:56:28+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v2.7.0",

200
config/audit.php Normal file
View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
return [
'enabled' => env('AUDITING_ENABLED', false),
/*
|--------------------------------------------------------------------------
| Audit Implementation
|--------------------------------------------------------------------------
|
| Define which Audit model implementation should be used.
|
*/
'implementation' => OwenIt\Auditing\Models\Audit::class,
/*
|--------------------------------------------------------------------------
| User Morph prefix & Guards
|--------------------------------------------------------------------------
|
| Define the morph prefix and authentication guards for the User resolver.
|
*/
'user' => [
'morph_prefix' => 'user',
'guards' => [
'web',
'api',
],
'resolver' => OwenIt\Auditing\Resolvers\UserResolver::class,
],
/*
|--------------------------------------------------------------------------
| Audit Resolvers
|--------------------------------------------------------------------------
|
| Define the IP Address, User Agent and URL resolver implementations.
|
*/
'resolvers' => [
'ip_address' => App\Extensions\Auditing\Resolvers\CustomIpAddressResolver::class,
'user_agent' => OwenIt\Auditing\Resolvers\UserAgentResolver::class,
'url' => OwenIt\Auditing\Resolvers\UrlResolver::class,
],
/*
|--------------------------------------------------------------------------
| Audit Events
|--------------------------------------------------------------------------
|
| The Eloquent events that trigger an Audit.
|
*/
'events' => [
'created',
'updated',
'deleted',
'restored',
],
/*
|--------------------------------------------------------------------------
| Strict Mode
|--------------------------------------------------------------------------
|
| Enable the strict mode when auditing?
|
*/
'strict' => true,
/*
|--------------------------------------------------------------------------
| Global exclude
|--------------------------------------------------------------------------
|
| Have something you always want to exclude by default? - add it here.
| Note that this is overwritten (not merged) with local exclude
|
*/
'exclude' => [],
/*
|--------------------------------------------------------------------------
| Empty Values
|--------------------------------------------------------------------------
|
| Should Audit records be stored when the recorded old_values & new_values
| are both empty?
|
| Some events may be empty on purpose. Use allowed_empty_values to exclude
| those from the empty values check. For example when auditing
| model retrieved events which will never have new and old values.
|
|
*/
'empty_values' => false,
'allowed_empty_values' => [
'retrieved',
],
/*
|--------------------------------------------------------------------------
| Allowed Array Values
|--------------------------------------------------------------------------
|
| Should the array values be audited?
|
| By default, array values are not allowed. This is to prevent performance
| issues when storing large amounts of data. You can override this by
| setting allow_array_values to true.
*/
'allowed_array_values' => true,
/*
|--------------------------------------------------------------------------
| Audit Timestamps
|--------------------------------------------------------------------------
|
| Should the created_at, updated_at and deleted_at timestamps be audited?
|
*/
'timestamps' => false,
/*
|--------------------------------------------------------------------------
| Audit Threshold
|--------------------------------------------------------------------------
|
| Specify a threshold for the amount of Audit records a model can have.
| Zero means no limit.
|
*/
'threshold' => 0,
/*
|--------------------------------------------------------------------------
| Audit Driver
|--------------------------------------------------------------------------
|
| The default audit driver used to keep track of changes.
|
*/
'driver' => 'database',
/*
|--------------------------------------------------------------------------
| Audit Driver Configurations
|--------------------------------------------------------------------------
|
| Available audit drivers and respective configurations.
|
*/
'drivers' => [
'database' => [
'table' => 'audits',
'connection' => null,
],
],
/*
|--------------------------------------------------------------------------
| Audit Queue Configurations
|--------------------------------------------------------------------------
|
| Available audit queue configurations.
|
*/
'queue' => [
'enable' => false,
'connection' => 'sync',
'queue' => 'default',
'delay' => 0,
],
/*
|--------------------------------------------------------------------------
| Audit Console
|--------------------------------------------------------------------------
|
| Whether console events should be audited (eg. php artisan db:seed).
|
*/
'console' => true,
];

View File

@@ -17,6 +17,10 @@ return [
'default' => env('FILESYSTEM_DISK', 'local'),
'public' => env('PUBLIC_FILESYSTEM_DISK', 'public'),
'private' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks

10
config/scheduling.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
return [
'tasks' => [
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
],
];

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Audit;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
/**
* @extends Factory<Audit>
*/
class AuditFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$morphPrefix = Config::get('audit.user.morph_prefix', 'user');
return [
$morphPrefix.'_id' => function () {
return User::factory()->create()->id;
},
$morphPrefix.'_type' => function () {
return (new User())->getMorphClass();
},
'event' => 'updated',
'auditable_id' => function () {
return User::factory()->create()->getKey();
},
'auditable_type' => function () {
return (new User())->getMorphClass();
},
'old_values' => [],
'new_values' => [],
'url' => $this->faker->url,
'ip_address' => $this->faker->ipv4,
'user_agent' => $this->faker->userAgent,
'tags' => implode(',', $this->faker->words(4)),
];
}
public function auditUser(User $user): self
{
return $this->state(function (array $attributes) use ($user) {
$morphPrefix = Config::get('audit.user.morph_prefix', 'user');
return [
$morphPrefix.'_id' => $user->getKey(),
$morphPrefix.'_type' => $user->getMorphClass(),
];
});
}
public function auditFor(Model $model): self
{
return $this->state(function (array $attributes) use ($model) {
return [
'auditable_id' => $model->getKey(),
'auditable_type' => $model->getMorphClass(),
];
});
}
}

Binary file not shown.

View File

@@ -0,0 +1,30 @@
<?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('time_entries', function (Blueprint $table) {
$table->dateTime('still_active_email_sent_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('time_entries', function (Blueprint $table) {
$table->dropColumn('still_active_email_sent_at');
});
}
};

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAuditsTable extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$connection = config('audit.drivers.database.connection', config('database.default'));
$table = config('audit.drivers.database.table', 'audits');
Schema::connection($connection)->create($table, function (Blueprint $table) {
$morphPrefix = config('audit.user.morph_prefix', 'user');
$table->bigIncrements('id');
$table->string($morphPrefix.'_type')->nullable();
$table->uuid($morphPrefix.'_id')->nullable();
$table->string('event');
$table->uuidMorphs('auditable');
$table->json('old_values')->nullable();
$table->json('new_values')->nullable();
$table->text('url')->nullable();
$table->ipAddress('ip_address')->nullable();
$table->string('user_agent', 1023)->nullable();
$table->string('tags')->nullable();
$table->timestamps();
$table->index([$morphPrefix.'_id', $morphPrefix.'_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$connection = config('audit.drivers.database.connection', config('database.default'));
$table = config('audit.drivers.database.table', 'audits');
Schema::connection($connection)->drop($table);
}
}

View File

@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace Database\Seeders;
use App\Enums\Role;
use App\Models\Audit;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Tag;
@@ -39,6 +41,9 @@ class DatabaseSeeder extends Seeder
'personal_team' => false,
'currency' => 'EUR',
]);
OrganizationInvitation::factory()->forOrganization($organizationAcme)->create([
'email' => 'new.employee@example.com',
]);
$userAcmeManager = User::factory()->withPersonalOrganization()->create([
'name' => 'Acme Manager',
'email' => 'test@example.com',
@@ -62,6 +67,15 @@ class DatabaseSeeder extends Seeder
$userAcmeEmployeeMember = Member::factory()->forUser($userAcmeEmployee)->forOrganization($organizationAcme)->role(Role::Employee)->create();
$userAcmePlaceholderMember = Member::factory()->forUser($userAcmePlaceholder)->forOrganization($organizationAcme)->role(Role::Placeholder)->create();
$userWithMultipleOrganizationsAcmeMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationAcme)->role(Role::Employee)->create();
Tag::factory()->forOrganization($organizationAcme)->create([
'name' => 'Code Review',
]);
Tag::factory()->forOrganization($organizationAcme)->create([
'name' => 'Meeting',
]);
Tag::factory()->forOrganization($organizationAcme)->create([
'name' => 'Research',
]);
TimeEntry::factory()
->count(10)
@@ -140,6 +154,7 @@ class DatabaseSeeder extends Seeder
private function deleteAll(): void
{
DB::table((new Audit())->getTable())->delete();
DB::table((new TimeEntry())->getTable())->delete();
DB::table((new Task())->getTable())->delete();
DB::table((new Tag())->getTable())->delete();
@@ -147,6 +162,7 @@ class DatabaseSeeder extends Seeder
DB::table((new Project())->getTable())->delete();
DB::table((new Client())->getTable())->delete();
DB::table((new User())->getTable())->delete();
DB::table((new OrganizationInvitation())->getTable())->delete();
DB::table((new Organization())->getTable())->delete();
}
}

View File

@@ -28,7 +28,6 @@ services:
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
SUPERVISOR_PHP_COMMAND: "/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan octane:start --server=frankenphp --host=0.0.0.0 --admin-port=2019 --port=80 --watch"
XDG_CONFIG_HOME: /var/www/html/config
XDG_DATA_HOME: /var/www/html/data
WWWUSER: '${WWWUSER}'
@@ -109,7 +108,7 @@ services:
- sail
- reverse-proxy
playwright:
image: mcr.microsoft.com/playwright:v1.44.1-jammy
image: mcr.microsoft.com/playwright:v1.46.1-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src
extra_hosts:

View File

@@ -170,6 +170,10 @@ COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/ph
# --no-dev \
# && composer clear-cache
RUN cat .env
RUN php artisan env
RUN php artisan storage:link
RUN chmod +x /usr/local/bin/start-container
RUN cat ${DOCKER_FILES_BASE_PATH}deployment/utilities.sh >> ~/.bashrc
@@ -177,5 +181,3 @@ RUN cat ${DOCKER_FILES_BASE_PATH}deployment/utilities.sh >> ~/.bashrc
EXPOSE 8000
ENTRYPOINT ["start-container"]
HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD php artisan octane:status || exit 1

View File

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

View File

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

View File

@@ -61,12 +61,6 @@ async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) {
);
}
async function assertThatTimeEntryRowIsStarted(newTimeEntry: Locator) {
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(
/bg-red-400\/80/
);
}
test('test that updating a description of a time entry in the overview works on blur', async ({
page,
}) => {
@@ -151,7 +145,8 @@ test('test that adding a new tag to an existing time entry works', async ({
const newTagName = Math.floor(Math.random() * 1000000).toString();
await newTimeEntry.getByTestId('time_entry_tag_dropdown').click();
await page.getByTestId('tag_dropdown_search').fill(newTagName);
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(newTagName);
const [tagReponse] = await Promise.all([
page.waitForResponse(async (response) => {
@@ -162,7 +157,7 @@ test('test that adding a new tag to an existing time entry works', async ({
(await response.json()).data.name === newTagName
);
}),
page.getByTestId('tag_dropdown_search').press('Enter'),
page.getByRole('button', { name: 'Create Tag' }).click(),
]);
await page.waitForResponse(async (response) => {
@@ -178,8 +173,7 @@ test('test that adding a new tag to an existing time entry works', async ({
);
});
await expect(page.getByTestId('tag_dropdown_search')).toHaveValue('');
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
await expect(newTimeEntry.getByText(newTagName)).toBeVisible();
});
// Test that Start / End Time Update Works
@@ -259,48 +253,6 @@ test('test that updating a the duration in the overview works on blur', async ({
).toHaveValue('0h 20min');
});
// Test that start stop button stops running timer
test('test that stopping a time entry from the overview works', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
assertThatTimerHasStarted(page),
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
),
]);
await page.waitForTimeout(1500);
const newTimeEntry = timeEntryRows.first();
const stopButton = newTimeEntry.getByTestId('timer_button');
await assertThatTimeEntryRowIsStarted(newTimeEntry);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(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
);
}),
stopButton.click(),
]);
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(
/bg-accent-300\/70/
);
});
// Test that start stop button stops running timer
test('test that starting a time entry from the overview works', async ({
page,
@@ -327,7 +279,8 @@ test('test that starting a time entry from the overview works', async ({
startButton.click(),
]);
await expect(startButton).toHaveClass(/bg-red-500\/80/);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
await Promise.all([
page.waitForResponse(async (response) => {
@@ -341,67 +294,7 @@ test('test that starting a time entry from the overview works', async ({
);
}),
startOrStopTimerWithButton(page),
expect(startButton).toHaveClass(/bg-accent-300\/70/),
]);
});
test('test that updating a the duration in the overview for a running timer works on blur', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
assertThatTimerHasStarted(page),
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
),
]);
await page.waitForTimeout(1500);
const newTimeEntry = timeEntryRows.first();
const startButton = newTimeEntry.getByTestId('timer_button');
await page.waitForTimeout(1500);
const timeEntryDurationInput = newTimeEntry.getByTestId(
'time_entry_duration_input'
);
await timeEntryDurationInput.fill('20min');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
// TODO! Actually check the value
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null
);
}),
timeEntryDurationInput.press('Tab'),
]);
await expect(page.getByTestId('time_entry_time')).toHaveValue('00:20:00');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(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
);
}),
startOrStopTimerWithButton(page),
expect(startButton).toHaveClass(/bg-accent-300\/70/),
assertThatTimerIsStopped(page),
]);
});
@@ -411,14 +304,16 @@ test('test that deleting a time entry from the overview works', async ({
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
const timeEntryCount = await timeEntryRows.count();
await expect(timeEntryRows).toHaveCount(1);
const newTimeEntry = timeEntryRows.first();
const actionsDropdown = newTimeEntry.getByTestId('time_entry_actions');
const actionsDropdown = newTimeEntry
.getByRole('button', { name: 'Actions for the time entry' })
.first();
await actionsDropdown.click();
const deleteButton = page.getByTestId('time_entry_delete');
const deleteButton = page.getByText('Delete');
await deleteButton.click();
await expect(timeEntryRows).toHaveCount(timeEntryCount - 1);
await expect(timeEntryRows).toHaveCount(0);
});
test.skip('test that load more works when the end of page is reached', async ({
@@ -468,3 +363,5 @@ test.skip('test that load more works when the end of page is reached', async ({
// TODO: Test Grouped time entries by description/project
// TODO: Add Test for Date Update
// TODO: Test that project can be created in the time entry row

View File

@@ -226,15 +226,17 @@ test('test that entering a time starts the timer on enter', async ({
test('test that adding a new tag works', async ({ page }) => {
const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);
await goToDashboard(page);
await page.getByTestId('tag_dropdown').click();
await page.getByTestId('tag_dropdown_search').fill(newTagName);
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(newTagName);
await Promise.all([
newTagResponse(page, { name: newTagName }),
page.getByTestId('tag_dropdown_search').press('Enter'),
page.getByRole('button', { name: 'Create Tag' }).click(),
]);
await expect(page.getByTestId('tag_dropdown_search')).toHaveValue('');
await page.getByTestId('tag_dropdown').click();
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
});
@@ -249,14 +251,16 @@ test('test that adding a new tag when the timer is running', async ({
]);
await assertThatTimerHasStarted(page);
await page.getByTestId('tag_dropdown').click();
await page.getByTestId('tag_dropdown_search').fill(newTagName);
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(newTagName);
const [tagCreateResponse] = await Promise.all([
newTagResponse(page, { name: newTagName }),
page.getByTestId('tag_dropdown_search').press('Enter'),
page.getByRole('button', { name: 'Create Tag' }).click(),
]);
const tagId = (await tagCreateResponse.json()).data.id;
await newTimeEntryResponse(page, { status: 200, tags: [tagId] });
await expect(page.getByTestId('tag_dropdown_search')).toHaveValue('');
await page.getByTestId('tag_dropdown').click();
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
await page.getByTestId('tag_dropdown_search').press('Escape');
await page.waitForTimeout(1000);
@@ -279,3 +283,7 @@ test('test that adding a new tag when the timer is running', async ({
// test that sidebar timetracker changes state when tmer on dashboard is started
// test billable toggle
// TODO: Test that project can be created in the time tracker row
// Add Test that time tracker starts on enter with description

View File

@@ -14,6 +14,7 @@ use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Service\Export\ExportException;
return [
'api' => [
@@ -29,6 +30,7 @@ return [
OnlyOwnerCanChangeOwnership::KEY => 'Only owner can change ownership',
OrganizationNeedsAtLeastOneOwner::KEY => 'Organization needs at least one owner',
ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed',
ExportException::KEY => 'Export failed, please try again later or contact support',
],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
];

View File

@@ -32,4 +32,8 @@ return [
'<br><br>1. Go to Admin -> Settings -> Data export. <br>2. Under "Time entries" select the year you want to export and click on "Export time entries". <br><br>You can export all years one after another and import them one after another. '.
' <br>Before you import make sure that the Timezone settings in Toggl are the same as in solidtime.',
],
'solidtime_importer' => [
'name' => 'Solidtime',
'description' => '1. Choose the organization you want to export in dropdown in the left top corner<br>2. Click on "Export" in the left navigation under "Admin" (You need to be Admin or Owner of the organization to see this)<br>3. Click on "Export". <br>4. Save the file and upload it here.',
],
];

1829
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
"lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore .",
"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 openapi.json.client.ts --base-url /api"
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api"
},
"devDependencies": {
"@inertiajs/vue3": "^1.0.0",
@@ -28,9 +28,9 @@
"tailwindcss": "^3.1.0",
"typescript": "^5.3.3",
"vite": "^5.0.0",
"vite-plugin-checker": "^0.6.2",
"vite-plugin-checker": "^0.7.2",
"vue": "^3.4.0",
"vue-tsc": "^1.8.27"
"vue-tsc": "^2.0.28"
},
"dependencies": {
"@floating-ui/core": "^1.6.0",
@@ -40,7 +40,7 @@
"@tailwindcss/container-queries": "^0.1.1",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vueuse/core": "^10.9.0",
"@vueuse/core": "^10.11.0",
"dayjs": "^1.11.11",
"echarts": "^5.5.0",
"parse-duration": "^1.1.0",

View File

@@ -0,0 +1,15 @@
version: 0.0.23
files:
- url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23.AppImage
sha512: O7JKg+rZOFPwaysl9KfrMdH2/KoJAaC+y+pq3JUAv0qG5Kr1eaLjIrzv5iJvWg+Ow+7n7xDGyeEd9ZojQ968OA==
size: 116853923
blockMapSize: 122039
- url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime_0.0.23_amd64.deb
sha512: U7Rwb+F7IZZbgJmWuIqMa9D3lfOkZlWgoqnNMZO+pX9SVMuskMEkLoFB5axEPI4JKtOWCUh5H9mDdqbmBbuoFw==
size: 78604326
- url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23.x86_64.rpm
sha512: xICjadHCip4vcpQt2mrewAZheI4gXO3csZxUo6Za8c1+eLCU5Wvdj+WaRGtz/+NIrzmiKJ7YmrSUhW2rs08qiQ==
size: 78917997
path: solidtime-0.0.23.AppImage
sha512: O7JKg+rZOFPwaysl9KfrMdH2/KoJAaC+y+pq3JUAv0qG5Kr1eaLjIrzv5iJvWg+Ow+7n7xDGyeEd9ZojQ968OA==
releaseDate: '2024-08-27T13:31:55.031Z'

View File

@@ -0,0 +1,17 @@
version: 0.0.23
files:
- url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23-mac.zip
sha512: /1dAPCgPV7PEE2KPQkHGme0YFPjHUqkGEvNkJj4Ncw1ZXFY5YK0+lr82XrnQiwCVUioylpWeQitdc+QEwW+2fQ==
size: 108473987
- url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23-arm64-mac.zip
sha512: TOuyxEI8i6IMdcE4vG0U6cU0IZDs6JR67/8uSISmhqiwyTwKLC+pLuiWn/ayNLfXDtAVPvGFq6NsyhSd6CXjbA==
size: 100954963
- url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23-x64.dmg
sha512: 9ib2nk7i149ihTB6GO3lyRM62dCzUigqLqNEGIa35Vw4CDeYIHBDfu+kTbHold0MphVAP36/Z7ZYUNXy+IDphg==
size: 112692794
- url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23-arm64.dmg
sha512: JGekA83DFOtzmAc93dcCyYneVLFLsDl/wq/lfCr3ejCYlcNip6be9EAr5MKFlqXQ/MaqjWfpweOVYJVM/S3hDQ==
size: 105162031
path: solidtime-0.0.23-mac.zip
sha512: /1dAPCgPV7PEE2KPQkHGme0YFPjHUqkGEvNkJj4Ncw1ZXFY5YK0+lr82XrnQiwCVUioylpWeQitdc+QEwW+2fQ==
releaseDate: '2024-08-27T13:30:08.202Z'

View File

@@ -0,0 +1,8 @@
version: 0.0.23
files:
- url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23-setup.exe
sha512: i3ju9ekS7scQlwOzocairegdhCKwEFKE9JppQF8SmLHXxD2aWxsW1uOCc9HcB+0KSo7/1IcnmsOMdHfIvjeW1Q==
size: 84101154
path: solidtime-0.0.23-setup.exe
sha512: i3ju9ekS7scQlwOzocairegdhCKwEFKE9JppQF8SmLHXxD2aWxsW1uOCc9HcB+0KSo7/1IcnmsOMdHfIvjeW1Q==
releaseDate: '2024-08-27T13:26:34.512Z'

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
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 { ref } from 'vue';
import type { CreateClientBody } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import type { CreateClientBody } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useClientsStore } from '@/utils/useClients';

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
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 { ref } from 'vue';
import type { Client, UpdateClientBody } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import type { Client, UpdateClientBody } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useClientsStore } from '@/utils/useClients';

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import { PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';
import type { Client } from '@/utils/api';
import type { Client } from '@/packages/api/src';
import { canDeleteClients, canUpdateClients } from '@/utils/permissions';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];
@@ -14,47 +14,29 @@ const props = defineProps<{
</script>
<template>
<Dropdown>
<template #trigger>
<svg
data-testid="client_actions"
:aria-label="'Actions for Client ' + props.client.name"
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</template>
<template #content>
<div class="min-w-[150px]">
<button
v-if="canUpdateClients()"
@click="emit('edit')"
:aria-label="'Edit Client ' + props.client.name"
data-testid="client_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
</button>
<button
v-if="canDeleteClients()"
@click="emit('delete')"
:aria-label="'Delete Client ' + props.client.name"
data-testid="client_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</div>
</template>
</Dropdown>
<MoreOptionsDropdown :label="'Actions for Client ' + props.client.name">
<div class="min-w-[150px]">
<button
v-if="canUpdateClients()"
@click="emit('edit')"
:aria-label="'Edit Client ' + props.client.name"
data-testid="client_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
</button>
<button
v-if="canDeleteClients()"
@click="emit('delete')"
:aria-label="'Delete Client ' + props.client.name"
data-testid="client_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</div>
</MoreOptionsDropdown>
</template>
<style scoped></style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import MultiselectDropdown from '@/Components/Common/MultiselectDropdown.vue';
import MultiselectDropdown from '@/packages/ui/src/Input/MultiselectDropdown.vue';
import { storeToRefs } from 'pinia';
import type { Client } from '@/utils/api';
import type { Client } from '@/packages/api/src';
import { useClientsStore } from '@/utils/useClients';
const clientsStore = useClientsStore();

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { UserCircleIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import { type Component, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useClientsStore } from '@/utils/useClients';
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
@@ -36,7 +36,7 @@ const createClient = ref(false);
<SecondaryButton
v-if="canCreateClients()"
@click="createClient = true"
:icon="PlusIcon"
:icon="PlusIcon as Component"
>Create your First Client
</SecondaryButton>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Client } from '@/utils/api';
import type { Client } from '@/packages/api/src';
import { computed, ref } from 'vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients';

View File

@@ -1,32 +0,0 @@
<script setup lang="ts">
import { formatDate, formatHumanReadableDate } from '@/utils/time';
defineProps<{
date: string;
}>();
</script>
<template>
<div class="flex items-center space-x-2">
<svg
class="w-4 sm:w-5"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path
d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7zm-5-9a1 1 0 0 1 1 1v1h2a2 2 0 0 1 2 2v3H3V7a2 2 0 0 1 2-2h2V4a1 1 0 0 1 2 0v1h6V4a1 1 0 0 1 1-1" />
</g>
</svg>
<span class="font-semibold text-white">
{{ formatHumanReadableDate(date) }}
</span>
<span class="font-semibold">
{{ formatDate(date) }}
</span>
</div>
</template>
<style scoped></style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import { TrashIcon, ArrowPathIcon } from '@heroicons/vue/20/solid';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];
resend: [];
@@ -8,39 +8,22 @@ const emit = defineEmits<{
</script>
<template>
<Dropdown align="bottom-end">
<template #trigger>
<svg
data-testid="invitation_actions"
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</template>
<template #content>
<button
@click="emit('resend')"
data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon>
<span>Resend Invitation</span>
</button>
<button
@click="emit('delete')"
data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</template>
</Dropdown>
<MoreOptionsDropdown label="Actions for the invitation">
<button
@click="emit('resend')"
data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon>
<span>Resend Invitation</span>
</button>
<button
@click="emit('delete')"
data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</MoreOptionsDropdown>
</template>
<style scoped></style>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { Invitation } from '@/utils/api';
import type { Invitation } from '@/packages/api/src';
import TableRow from '@/Components/TableRow.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import InvitationMoreOptionsDropdown from '@/Components/Common/Invitation/InvitationMoreOptionsDropdown.vue';
import { api } from '../../../../../openapi.json.client';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useInvitationsStore } from '@/utils/useInvitations';
@@ -18,15 +18,12 @@ async function deleteInvitation() {
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.removeInvitation(
{},
{
params: {
invitation: props.invitation.id,
organization: organizationId,
},
}
),
api.removeInvitation(undefined, {
params: {
invitation: props.invitation.id,
organization: organizationId,
},
}),
'Invitation removed successfully',
'Error removing invitation',
() => {

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { formatCents } from '../../../utils/money';
import BillableRateModal from '@/Components/Common/BillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
@@ -25,7 +26,10 @@ defineEmits<{
The billable rate of {{ memberName }} will be updated to
<strong>{{
newBillableRate
? formatCents(newBillableRate)
? formatCents(
newBillableRate,
getOrganizationCurrencyString()
)
: ' the default rate of the organization'
}}</strong
>.

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
import type { BillableKey } from '@/utils/useProjects';
import Badge from '@/Components/Common/Badge.vue';
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import type { BillableKey } from '@/types/projects';
const model = defineModel<BillableKey>({
default: 'default-rate',

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import ClientDropdownItem from '@/Components/Common/Client/ClientDropdownItem.vue';
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
import { useMembersStore } from '@/utils/useMembers';
import { UserIcon, XMarkIcon } from '@heroicons/vue/24/solid';
import TextInput from '@/Components/TextInput.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { useFocus } from '@vueuse/core';
import type { ProjectMember } from '@/utils/api';
import Dropdown from '@/Components/Dropdown.vue';
import type { ProjectMember } from '@/packages/api/src';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
const membersStore = useMembersStore();
const { members } = storeToRefs(membersStore);
@@ -35,7 +35,7 @@ function isMemberSelected(id: string) {
return model.value === id;
}
const { focused } = useFocus(searchInput, { initialValue: true });
useFocus(searchInput, { initialValue: true });
const filteredMembers = computed(() => {
return members.value.filter((member) => {
@@ -44,7 +44,7 @@ const filteredMembers = computed(() => {
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '') &&
!props.hiddenMembers.some(
(hiddenMember) => hiddenMember.id === member.id
(hiddenMember) => hiddenMember.member_id === member.id
) &&
member.is_placeholder === false
);
@@ -142,49 +142,40 @@ const hasMemberSelected = computed(() => {
});
const showMembersDropdown = ref(true);
function onUnfocus() {
// TODO this is a hack to prevent the dropdown from closing when clicking on the dropdown
setTimeout(() => {
if (!focused.value) {
showMembersDropdown.value = false;
}
}, 100);
}
</script>
<template>
<div class="flex relative">
<div
ref="reference"
class="absolute h-full items-center px-3 w-full flex justify-between">
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
<button
v-if="hasMemberSelected"
@click="model = ''"
class="focus:text-accent-200 focus:bg-card-background text-muted">
<XMarkIcon class="relative z-10 w-4"></XMarkIcon>
</button>
</div>
<TextInput
:value="currentValue"
:disabled="disabled"
@input="updateSearchValue"
data-testid="member_dropdown_search"
@keydown.enter.prevent="updateMember(highlightedItemId)"
@keydown.up.prevent="moveHighlightUp"
class="relative w-full pl-10"
@keydown.down.prevent="moveHighlightDown"
@focusin="showMembersDropdown = true"
@blur="onUnfocus"
placeholder="Search for a member..."
ref="searchInput" />
</div>
<Dropdown
align="bottom-start"
width="300"
v-model="showMembersDropdown"
:closeOnContentClick="true">
<template #trigger>
<div class="flex relative">
<div
ref="reference"
class="absolute h-full items-center px-3 w-full flex justify-between">
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
<button
v-if="hasMemberSelected"
@click="model = ''"
class="focus:text-accent-200 focus:bg-card-background text-muted">
<XMarkIcon class="relative z-10 w-4"></XMarkIcon>
</button>
</div>
<TextInput
:value="currentValue"
:disabled="disabled"
@input="updateSearchValue"
data-testid="member_dropdown_search"
@keydown.enter.prevent="updateMember(highlightedItemId)"
@keydown.up.prevent="moveHighlightUp"
class="relative w-full pl-10"
@keydown.down.prevent="moveHighlightDown"
placeholder="Search for a member..."
ref="searchInput" />
</div>
</template>
<template #content>
<div
class="py-2 text-white px-3"

View File

@@ -1,17 +1,18 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.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 { Member, UpdateMemberBody } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import type { Member, UpdateMemberBody } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { type MemberBillableKey, useMembersStore } from '@/utils/useMembers';
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
import InputLabel from '@/Components/InputLabel.vue';
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import MemberBillableRateModal from '@/Components/Common/Member/MemberBillableRateModal.vue';
import MemberBillableSelect from '@/Components/Common/Member/MemberBillableSelect.vue';
import { onMounted, watch } from 'vue';
import MemberRoleSelect from '@/Components/Common/Member/MemberRoleSelect.vue';
import MemberOwnershipTransferConfirmModal from '@/Components/Common/Member/MemberOwnershipTransferConfirmModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
const { updateMember } = useMembersStore();
const show = defineModel('show', { default: false });
@@ -154,6 +155,7 @@ const roleDescription = computed(() => {
<BillableRateInput
focus
class="w-full"
:currency="getOrganizationCurrencyString()"
@keydown.enter="saveWithChecks()"
name="memberBillableRate"
v-model="

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
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 { ref } from 'vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import InputLabel from '@/Components/InputLabel.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import InputError from '@/packages/ui/src/Input/InputError.vue';
import type { Role } from '@/types/jetstream';
import { Link, useForm } from '@inertiajs/vue3';
import { getCurrentOrganizationId } from '@/utils/useUser';
@@ -14,8 +14,8 @@ import { filterRoles } from '@/utils/roles';
import { hasActiveSubscription, isBillingActivated } from '@/utils/billing';
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { canUpdateOrganization } from '@/utils/permissions';
import { api } from '../../../../../openapi.json.client';
import type { MemberRole } from '@/utils/api';
import { api } from '@/packages/api/src';
import type { MemberRole } from '@/packages/api/src';
import { z } from 'zod';
import { useNotificationsStore } from '@/utils/notification';

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import type { Member } from '@/utils/api';
import type { Member } from '@/packages/api/src';
import { canDeleteMembers, canUpdateMembers } from '@/utils/permissions';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];
@@ -14,50 +14,30 @@ const props = defineProps<{
</script>
<template>
<Dropdown
<MoreOptionsDropdown
v-if="canUpdateMembers() || canDeleteMembers()"
align="bottom-end">
<template #trigger>
:label="'Actions for Member ' + props.member.name">
<div class="min-w-[150px]">
<button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-1 focus-visible:ring-input-border-active focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity"
:aria-label="'Actions for Member ' + props.member.name">
<svg
class="h-10 w-10 p-2 rounded-full"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
v-if="canUpdateMembers()"
@click="emit('edit')"
:aria-label="'Edit Member ' + props.member.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
</button>
</template>
<template #content>
<div class="min-w-[150px]">
<button
v-if="canUpdateMembers()"
@click="emit('edit')"
:aria-label="'Edit Member ' + props.member.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
</button>
<button
v-if="canDeleteMembers()"
@click="emit('delete')"
:aria-label="'Delete Member ' + props.member.name"
data-testid="member_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</div>
</template>
</Dropdown>
<button
v-if="canDeleteMembers()"
@click="emit('delete')"
:aria-label="'Delete Member ' + props.member.name"
data-testid="member_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</div>
</MoreOptionsDropdown>
</template>
<style scoped></style>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import MultiselectDropdown from '@/Components/Common/MultiselectDropdown.vue';
import MultiselectDropdown from '@/packages/ui/src/Input/MultiselectDropdown.vue';
import { useMembersStore } from '@/utils/useMembers';
import { storeToRefs } from 'pinia';
import type { Member } from '@/utils/api';
import type { Member } from '@/packages/api/src';
const membersStore = useMembersStore();
const { members } = storeToRefs(membersStore);

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
import Badge from '@/Components/Common/Badge.vue';
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import type { Role } from '@/types/jetstream';
import { usePage } from '@inertiajs/vue3';

View File

@@ -1,18 +1,19 @@
<script setup lang="ts">
import type { Member } from '@/utils/api';
import type { Member } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid';
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
import TableRow from '@/Components/TableRow.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import { api } from '../../../../../openapi.json.client';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { canInvitePlaceholderMembers } from '@/utils/permissions';
import { useMembersStore } from '@/utils/useMembers';
import { ref } from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import { formatCents } from '../../../utils/money';
import { getOrganizationCurrencyString } from '@/utils/money';
import { formatCents } from '@/packages/ui/src/utils/money';
const props = defineProps<{
member: Member;
@@ -62,7 +63,12 @@ async function invitePlaceholder(id: string) {
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
{{
member.billable_rate ? formatCents(member.billable_rate) : '--'
member.billable_rate
? formatCents(
member.billable_rate,
getOrganizationCurrencyString()
)
: '--'
}}
</div>
<div

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { formatCents } from '../../../utils/money';
import BillableRateModal from '@/Components/Common/BillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
@@ -23,7 +24,12 @@ defineEmits<{
<p class="py-0.5 text-center">
The organization billable rate will be updated to
<strong>{{
newBillableRate ? formatCents(newBillableRate) : ' none.'
newBillableRate
? formatCents(
newBillableRate,
getOrganizationCurrencyString()
)
: ' none.'
}}</strong
>.
</p>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import ProjectBadge from '@/Components/Common/Project/ProjectBadge.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 '@/Components/Dropdown.vue';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import {
ComboboxAnchor,
ComboboxContent,
@@ -12,12 +12,12 @@ import {
ComboboxViewport,
} from 'radix-vue';
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import ProjectDropdownItem from '@/Components/Common/Project/ProjectDropdownItem.vue';
import { storeToRefs } from 'pinia';
import { api } from '../../../../../openapi.json.client';
import { api } from '@/packages/api/src';
import { usePage } from '@inertiajs/vue3';
import { getRandomColor } from '@/utils/color';
import type { Project } from '@/utils/api';
import { getRandomColor } from '@/packages/ui/src/utils/color';
import type { Project } from '@/packages/api/src';
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
const searchValue = ref('');
const searchInput = ref<HTMLElement | null>(null);

View File

@@ -1,21 +1,26 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
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 { CreateProjectBody, Project } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
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';
import ClientDropdown from '@/Components/Common/Client/ClientDropdown.vue';
import Badge from '@/Components/Common/Badge.vue';
import ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue';
import ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import InputLabel from '@/Components/InputLabel.vue';
import ProjectBillableRateModal from '@/Components/Common/Project/ProjectBillableRateModal.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
const { updateProject } = useProjectsStore();
const { clients } = storeToRefs(useClientsStore());
@@ -26,6 +31,10 @@ const props = defineProps<{
originalProject: Project;
}>();
async function createClient(body: CreateClientBody) {
return await useClientsStore().createClient(body);
}
const project = ref<CreateProjectBody>({
name: props.originalProject.name,
color: props.originalProject.color,
@@ -97,7 +106,11 @@ async function submitBillableRate() {
</div>
<div class="">
<InputLabel for="client" value="Client" />
<ClientDropdown class="mt-1" v-model="project.client_id">
<ClientDropdown
:createClient
:clients="clients"
class="mt-1"
v-model="project.client_id">
<template #trigger>
<Badge
class="bg-input-background cursor-pointer hover:bg-tertiary"
@@ -116,6 +129,7 @@ async function submitBillableRate() {
</div>
<ProjectEditBillableSection
@submit="submit"
:currency="getOrganizationCurrencyString()"
v-model:isBillable="project.is_billable"
v-model:billableRate="
project.billable_rate
@@ -135,6 +149,7 @@ async function submitBillableRate() {
</DialogModal>
<ProjectBillableRateModal
v-model:show="showBillableRateModal"
:currency="getOrganizationCurrencyString()"
@submit="submitBillableRate"
:new-billable-rate="project.billable_rate"
:project-name="project.name"></ProjectBillableRateModal>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import {
TrashIcon,
PencilSquareIcon,
ArchiveBoxIcon,
} from '@heroicons/vue/20/solid';
import type { Project } from '@/utils/api';
import type { Project } from '@/packages/api/src';
import { canDeleteProjects, canUpdateProjects } from '@/utils/permissions';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];
edit: [];
@@ -18,61 +18,37 @@ const props = defineProps<{
</script>
<template>
<Dropdown>
<template #trigger>
<MoreOptionsDropdown :label="'Actions for Project ' + props.project.name">
<div class="min-w-[150px]">
<button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-1 focus-visible:ring-input-border-active focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity"
data-testid="project_actions"
:aria-label="'Actions for Project ' + props.project.name">
<svg
class="h-10 w-10 p-2 rounded-full"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
@click.prevent="emit('edit')"
v-if="canUpdateProjects()"
:aria-label="'Edit Project ' + props.project.name"
data-testid="project_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
</button>
</template>
<template #content>
<div class="min-w-[150px]">
<button
@click.prevent="emit('edit')"
v-if="canUpdateProjects()"
:aria-label="'Edit Project ' + props.project.name"
data-testid="project_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
</button>
<button
@click.prevent="emit('archive')"
v-if="canUpdateProjects()"
:aria-label="'Archive Project ' + props.project.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<ArchiveBoxIcon
class="w-5 text-icon-active"></ArchiveBoxIcon>
<span>{{
project.is_archived ? 'Unarchive' : 'Archive'
}}</span>
</button>
<button
@click.prevent="emit('delete')"
:aria-label="'Delete Project ' + props.project.name"
data-testid="project_delete"
v-if="canDeleteProjects()"
class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</div>
</template>
</Dropdown>
<button
@click.prevent="emit('archive')"
v-if="canUpdateProjects()"
:aria-label="'Archive Project ' + props.project.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
<span>{{ project.is_archived ? 'Unarchive' : 'Archive' }}</span>
</button>
<button
@click.prevent="emit('delete')"
:aria-label="'Delete Project ' + props.project.name"
data-testid="project_delete"
v-if="canDeleteProjects()"
class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</div>
</MoreOptionsDropdown>
</template>
<style scoped></style>

Some files were not shown because too many files have changed in this diff Show More