Compare commits

...

40 Commits

Author SHA1 Message Date
Gregor Vostrak
5e3ac45ce0 make currency and createProject permission props in the create time entry modal, bump ui version 2024-11-13 13:04:12 +01:00
Constantin Graf
2cf9b3aa8f Fix force https for some reverse proxies 2024-11-12 21:50:26 +01:00
Constantin Graf
64b41e3018 Fix force https for some reverse proxies, Add url and path to debug endpoint 2024-11-12 19:03:36 +01:00
Gregor Vostrak
31014c1e29 fix type import api reference 2024-11-12 18:58:59 +01:00
Gregor Vostrak
d880717749 add TimeEntryCreateModal and MoreOptionsDropdown to ui package 2024-11-12 18:54:54 +01:00
Gregor Vostrak
df0f3b2680 patch new time entries into existing store when stores are refreshed on focus 2024-11-12 17:38:04 +01:00
Gregor Vostrak
4b0cb2e282 improve time picker parsing, fix nested escape listeners, change project member select 2024-11-12 16:07:51 +01:00
Gregor Vostrak
d5699da234 improve manual time entry modal, improve time picker, add human duration input 2024-11-12 16:07:51 +01:00
Constantin Graf
96f06bae1d Update README.md 2024-11-12 13:52:31 +01:00
Gregor Vostrak
e1243178fe Update README.md 2024-11-12 13:50:33 +01:00
Gregor Vostrak
cfbc98705a add bug report and feature request rules to the README 2024-11-12 13:48:04 +01:00
Gregor Vostrak
f0d6b234e5 add github sponsor information 2024-11-11 17:23:23 +01:00
Constantin Graf
4b622afcfc Change logic of tags_ids filter from AND to OR 2024-11-08 13:28:26 +01:00
Constantin Graf
45daeead61 Fix billable contract for self-hosting 2024-11-07 16:12:42 +01:00
Constantin Graf
95c1bcd4cb Change precheck order in migrations 2024-11-05 12:32:51 +01:00
Constantin Graf
3b3f593080 Fix foreign keys and deletion service 2024-11-05 12:09:04 +01:00
Constantin Graf
4224fdd57e Fixed report for query with no entries 2024-11-01 13:46:22 +01:00
Constantin Graf
f4cfeaa718 Fixed issue with daylight saving time in chart 2024-10-30 17:40:46 +01:00
Constantin Graf
04fcc1e3ae Fixed timezones in detailed export reports #2 2024-10-29 18:25:42 +01:00
Constantin Graf
f145e821a8 Fix incorrect grouping by billable in export report 2024-10-29 18:09:22 +01:00
Constantin Graf
eaaa83406d Fixed timezones in detailed export reports 2024-10-29 18:09:22 +01:00
Constantin Graf
9a60e2b911 Add tests for export endpoints 2024-10-29 17:20:21 +01:00
Gregor Vostrak
5a1e05374c disable pdf export button 2024-10-29 17:20:21 +01:00
Gregor Vostrak
ab4dbd64df add support for history_group and loading indicators to export buttons 2024-10-29 17:20:21 +01:00
Constantin Graf
8712cfb9dc Add report exports 2024-10-29 17:20:21 +01:00
Gregor Vostrak
7c1fe35754 add export buttons for aggregated export and pdf export 2024-10-29 17:20:21 +01:00
Constantin Graf
b0bcc4f330 Add pdf detailed report and placeholder for aggregate endpoint 2024-10-29 17:20:21 +01:00
Gregor Vostrak
5593d141ea automatically select project after create in time tracker component, fixes ST-457 2024-10-29 17:20:21 +01:00
Gregor Vostrak
d080b07e60 add Export download buttons 2024-10-29 17:20:21 +01:00
Constantin Graf
64535ceea6 Add report exports 2024-10-29 17:20:21 +01:00
Gregor Vostrak
e54df74d5d improve typing in solidtime ui package 2024-10-28 14:54:48 +01:00
Constantin Graf
27b40d863e Make email validation on registration stricter 2024-10-28 14:32:27 +01:00
Gregor Vostrak
b41d20839e improve empty state texts for employees 2024-10-28 14:24:40 +01:00
Gregor Vostrak
7acadda6d8 bump ui and api package versions 2024-10-28 14:14:50 +01:00
Gregor Vostrak
cd7573dcf1 hide create project buttons and modal depending on the permission 2024-10-28 14:14:50 +01:00
Gregor Vostrak
eb4debe481 move time entry mass updates to ui package and remove its dependencies 2024-10-28 14:14:50 +01:00
Constantin Graf
fd77e1e901 Fix logo for email client with no SVG support like Gmail 2024-10-28 12:21:57 +01:00
Constantin Graf
401cd4be0a Fixed setting multiple time entry description to an empty string 2024-10-22 16:45:21 +02:00
Gregor Vostrak
548307336a keep tags when starting a new time entry from a finished one, fixes ST-469 2024-10-22 13:27:30 +02:00
Constantin Graf
f534f90ca7 Fix force HTTPS config 2024-10-22 11:09:31 +02:00
109 changed files with 5530 additions and 767 deletions

22
.env.ci
View File

@@ -6,12 +6,13 @@ APP_URL=http://localhost
APP_FORCE_HTTPS=false APP_FORCE_HTTPS=false
SESSION_SECURE_COOKIE=false SESSION_SECURE_COOKIE=false
# Logging
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
# Database
DB_CONNECTION=pgsql_test DB_CONNECTION=pgsql_test
DB_TEST_HOST=127.0.0.1 DB_TEST_HOST=127.0.0.1
DB_TEST_PORT=5432 DB_TEST_PORT=5432
DB_TEST_DATABASE=laravel DB_TEST_DATABASE=laravel
@@ -20,26 +21,21 @@ DB_TEST_PASSWORD=root
BROADCAST_DRIVER=log BROADCAST_DRIVER=log
CACHE_DRIVER=file CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync QUEUE_CONNECTION=sync
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1 # Mail
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log MAIL_MAILER=log
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
S3_ACCESS_KEY_ID= # Filesystems
S3_SECRET_ACCESS_KEY= FILESYSTEM_DISK=local
S3_REGION=us-east-1 PUBLIC_FILESYSTEM_DISK=public
S3_BUCKET=
S3_USE_PATH_STYLE_ENDPOINT=false # Services
GOTENBERG_URL=http://0.0.0.0:3000
PUSHER_APP_ID= PUSHER_APP_ID=
PUSHER_APP_KEY= PUSHER_APP_KEY=

View File

@@ -4,15 +4,15 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true APP_DEBUG=true
APP_URL=https://solidtime.test APP_URL=https://solidtime.test
AUDITING_ENABLED=true AUDITING_ENABLED=true
SUPER_ADMINS=admin@example.com SUPER_ADMINS=admin@example.com
# Logging
LOG_CHANNEL=single LOG_CHANNEL=single
LOG_DEPRECATIONS_CHANNEL=deprecation LOG_DEPRECATIONS_CHANNEL=deprecation
LOG_LEVEL=debug LOG_LEVEL=debug
# Database
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST=pgsql DB_HOST=pgsql
DB_PORT=5432 DB_PORT=5432
DB_DATABASE=laravel DB_DATABASE=laravel
@@ -31,12 +31,7 @@ QUEUE_CONNECTION=sync
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1 # Mail
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp MAIL_MAILER=smtp
MAIL_HOST=mailpit MAIL_HOST=mailpit
MAIL_PORT=1025 MAIL_PORT=1025
@@ -54,7 +49,7 @@ PUSHER_PORT=443
PUSHER_SCHEME=https PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1 PUSHER_APP_CLUSTER=mt1
# Storage # Filesystems
FILESYSTEM_DISK=s3 FILESYSTEM_DISK=s3
PUBLIC_FILESYSTEM_DISK=s3 PUBLIC_FILESYSTEM_DISK=s3
S3_ACCESS_KEY_ID=sail S3_ACCESS_KEY_ID=sail
@@ -65,6 +60,9 @@ S3_URL=http://storage.solidtime.test/local
S3_ENDPOINT=http://storage.solidtime.test S3_ENDPOINT=http://storage.solidtime.test
S3_USE_PATH_STYLE_ENDPOINT=true S3_USE_PATH_STYLE_ENDPOINT=true
# Services
GOTENBERG_URL=http://gotenberg:3000
VITE_HOST_NAME=vite.solidtime.test VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: solidtime-io

View File

@@ -20,7 +20,15 @@ jobs:
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
gotenberg:
image: gotenberg/gotenberg:8
ports:
- 3000:3000
options: >-
--health-cmd "curl --silent --fail http://localhost:3000/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -28,6 +28,11 @@ We also have an examples repository [here](https://github.com/solidtime-io/self-
If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/) If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)
## Issues & Feature Requests
If you find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug.
If you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
## Contributing ## Contributing
This project is in a very early stage. The structure and APIs are still subject to change and not stable. This project is in a very early stage. The structure and APIs are still subject to change and not stable.
@@ -35,6 +40,8 @@ Therefore, we do not currently accept any contributions, unless you are a member
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions. As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
## Security ## Security
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file. Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.

View File

@@ -43,7 +43,7 @@ class CreateNewUser implements CreatesNewUsers
'email' => [ 'email' => [
'required', 'required',
'string', 'string',
'email', 'email:rfc,strict',
'max:255', 'max:255',
UniqueEloquent::make(User::class, 'email', function (Builder $builder): Builder { UniqueEloquent::make(User::class, 'email', function (Builder $builder): Builder {
/** @var Builder<User> $builder */ /** @var Builder<User> $builder */

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Maatwebsite\Excel\Excel;
enum ExportFormat: string
{
case CSV = 'csv';
case PDF = 'pdf';
case XLSX = 'xlsx';
case ODS = 'ods';
public function getFileExtension(): string
{
return match ($this) {
self::CSV => 'csv',
self::PDF => 'pdf',
self::XLSX => 'xlsx',
self::ODS => 'ods',
};
}
public function getExportPackageType(): string
{
return match ($this) {
self::CSV => Excel::CSV,
self::PDF => Excel::MPDF,
self::XLSX => Excel::XLSX,
self::ODS => Excel::ODS,
};
}
}

View File

@@ -4,8 +4,12 @@ declare(strict_types=1);
namespace App\Enums; namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryAggregationType: string enum TimeEntryAggregationType: string
{ {
use LaravelEnumHelper;
case Day = 'day'; case Day = 'day';
case Week = 'week'; case Week = 'week';
case Month = 'month'; case Month = 'month';
@@ -17,6 +21,16 @@ enum TimeEntryAggregationType: string
case Billable = 'billable'; case Billable = 'billable';
case Description = 'description'; case Description = 'description';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{
return match ($timeEntryAggregationTypeInterval) {
TimeEntryAggregationTypeInterval::Day => TimeEntryAggregationType::Day,
TimeEntryAggregationTypeInterval::Week => TimeEntryAggregationType::Week,
TimeEntryAggregationTypeInterval::Month => TimeEntryAggregationType::Month,
TimeEntryAggregationTypeInterval::Year => TimeEntryAggregationType::Year,
};
}
public function toInterval(): ?TimeEntryAggregationTypeInterval public function toInterval(): ?TimeEntryAggregationTypeInterval
{ {
return match ($this) { return match ($this) {

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
{
public const string KEY = 'feature_is_not_available_in_free_plan';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class PdfRendererIsNotConfiguredException extends ApiException
{
public const string KEY = 'pdf_renderer_is_not_configured';
}

View File

@@ -4,10 +4,15 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryDestroyMultipleRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryDestroyMultipleRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest;
@@ -21,15 +26,29 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\ReportExport\TimeEntriesReportExport;
use App\Service\TimeEntryAggregationService; use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter; use App\Service\TimeEntryFilter;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Gotenberg\Exceptions\GotenbergApiErrored;
use Gotenberg\Exceptions\NoOutputFileInResponse;
use Gotenberg\Gotenberg;
use Gotenberg\Stream;
use GuzzleHttp\Client;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller class TimeEntryController extends Controller
{ {
@@ -42,7 +61,7 @@ class TimeEntryController extends Controller
} }
/** /**
* Get all time entries in organization * Get time entries in organization
* *
* If you only need time entries for a specific user, you can filter by `user_id`. * If you only need time entries for a specific user, you can filter by `user_id`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter. * Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
@@ -63,21 +82,7 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
} }
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
->whereBelongsTo($organization, 'organization')
->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start'));
$filter->addEndFilter($request->input('end'));
$filter->addActiveFilter($request->input('active'));
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
$totalCount = $timeEntriesQuery->count(); $totalCount = $timeEntriesQuery->count();
@@ -128,6 +133,115 @@ class TimeEntryController extends Controller
]); ]);
} }
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
{
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start'));
$filter->addEndFilter($request->input('end'));
$filter->addActiveFilter($request->input('active'));
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
return $filter->get();
}
/**
* Export time entries in organization
*
* @throws AuthorizationException|PdfRendererIsNotConfiguredException|FeatureIsNotAvailableInFreePlanApiException
*
* @operationId exportTimeEntries
*/
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request): JsonResponse
{
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$user = $this->user();
$timezone = $user->timezone;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([
'task',
'client',
'project',
'user',
'tagsRelation',
]);
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports';
$path = $folderPath.'/'.$filename;
if ($format === ExportFormat::CSV) {
$export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone);
$export->export();
} elseif ($format === ExportFormat::PDF) {
if (config('services.gotenberg.url') === null) {
throw new PdfRendererIsNotConfiguredException;
}
$viewFile = file_get_contents(resource_path('views/reports/time-entry-index.blade.php'));
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$html = Blade::render($viewFile, ['timeEntries' => $timeEntriesQuery->get()]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
$client = new Client([
'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [
config('services.gotenberg.basic_auth_username'),
config('services.gotenberg.basic_auth_password'),
] : null,
]);
$request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf()
->pdfa('PDF/A-3b')
->paperSize('8.27', '11.7') // A4
->footer(Stream::string('footer', $footerHtml))
->html(Stream::string('body', $html));
$tempFolder = TemporaryDirectory::make();
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);
Storage::disk(config('filesystems.private'))
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
[
'visibility' => 'private',
]
);
}
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
]);
}
/** /**
* Get aggregated time entries in organization * Get aggregated time entries in organization
* *
@@ -160,7 +274,7 @@ class TimeEntryController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $aggregationService): array public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
{ {
/** @var Member|null $member */ /** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
@@ -169,7 +283,146 @@ class TimeEntryController extends Controller
} else { } else {
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
} }
$user = $this->user();
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
$group1Type,
$group2Type,
$user->timezone,
$user->week_start,
$request->getFillGapsInTimeGroups(),
$request->getStart(),
$request->getEnd()
);
return [
'data' => $aggregatedData,
];
}
/**
* Export aggregated time entries in organization
*
* @operationId exportAggregatedTimeEntries
*
* @throws AuthorizationException
* @throws PdfRendererIsNotConfiguredException
* @throws GotenbergApiErrored
* @throws NoOutputFileInResponse
* @throws FeatureIsNotAvailableInFreePlanApiException
*/
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$user = $this->user();
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesAggregateQuery->clone(),
$group,
$subGroup,
$user->timezone,
$user->week_start,
false,
$request->getStart(),
$request->getEnd()
);
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(),
$request->getHistoryGroup(),
null,
$user->timezone,
$user->week_start,
true,
$request->getStart(),
$request->getEnd()
);
$currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports';
$path = $folderPath.'/'.$filename;
if ($format === ExportFormat::PDF) {
if (config('services.gotenberg.url') === null) {
throw new PdfRendererIsNotConfiguredException;
}
$client = new Client([
'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [
config('services.gotenberg.basic_auth_username'),
config('services.gotenberg.basic_auth_password'),
] : null,
]);
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php'));
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$html = Blade::render($viewFile, [
'aggregatedData' => $aggregatedData,
'dataHistoryChart' => $dataHistoryChart,
'currency' => $currency,
'group' => $group,
'subGroup' => $subGroup,
'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone),
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
$request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf()
->pdfa('PDF/A-3b')
->paperSize('8.27', '11.7') // A4
->footer(Stream::string('footer', $footerHtml))
->html(Stream::string('body', $html));
$tempFolder = TemporaryDirectory::make();
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);
Storage::disk(config('filesystems.private'))
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
[
'visibility' => 'private',
]
);
}
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
]);
}
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
{
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization'); ->whereBelongsTo($organization, 'organization');
@@ -184,27 +437,8 @@ class TimeEntryController extends Controller
$filter->addTaskIdsFilter($request->input('task_ids')); $filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids')); $filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable')); $filter->addBillableFilter($request->input('billable'));
$timeEntriesQuery = $filter->get();
$user = $this->user(); return $filter->get();
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$aggregatedData = $aggregationService->getAggregatedTimeEntries(
$timeEntriesQuery,
$group1Type,
$group2Type,
$user->timezone,
$user->week_start,
$request->getFillGapsInTimeGroups(),
$request->getStart(),
$request->getEnd()
);
return [
'data' => $aggregatedData,
];
} }
/** /**
@@ -333,6 +567,10 @@ class TimeEntryController extends Controller
$changes = $request->validated('changes'); $changes = $request->validated('changes');
if ($request->has('changes.description')) {
$changes['description'] = $request->input('changes.description') ?? '';
}
if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) { if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) {
throw new AuthorizationException; throw new AuthorizationException;
} }

View File

@@ -48,6 +48,8 @@ class HealthCheckController extends Controller
return response() return response()
->json([ ->json([
'ip_address' => $ipAddress, 'ip_address' => $ipAddress,
'url' => $request->url(),
'path' => $request->path(),
'hostname' => $hostname, 'hostname' => $hostname,
'timestamp' => Carbon::now()->timestamp, 'timestamp' => Carbon::now()->timestamp,
'date_time_utc' => Carbon::now('UTC')->toDateTimeString(), 'date_time_utc' => Carbon::now('UTC')->toDateTimeString(),

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryAggregateExportRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
'format' => [
'required',
'string',
Rule::enum(ExportFormat::class),
],
'group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
'sub_group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
'history_group' => [
'required',
'nullable',
Rule::enum(TimeEntryAggregationTypeInterval::class),
],
// Filter by member ID
'member_id' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
'member_ids' => [
'array',
'min:1',
],
'member_ids.*' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by user ID
'user_id' => [
'string',
ExistsEloquent::make(User::class, null, function (Builder $builder): Builder {
/** @var Builder<User> $builder */
return $builder->belongsToOrganization($this->organization);
})->uuid(),
],
// Filter by project IDs, project IDs are OR combined
'project_ids' => [
'array',
'min:1',
],
'project_ids.*' => [
'string',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by client IDs, client IDs are OR combined
'client_ids' => [
'array',
'min:1',
],
'client_ids.*' => [
'string',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [
'array',
'min:1',
],
'tag_ids.*' => [
'string',
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
'min:1',
],
'task_ids.*' => [
'string',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [
'required',
'string',
'date_format:Y-m-d\TH:i:s\Z',
'before:end',
],
// Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'end' => [
'required',
'string',
'date_format:Y-m-d\TH:i:s\Z',
],
// Filter by active status (active means has no end date, is still running)
'active' => [
'string',
'in:true,false',
],
// Filter by billable status
'billable' => [
'string',
'in:true,false',
],
'fill_gaps_in_time_groups' => [
'string',
'in:true,false',
],
];
}
public function getGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('group'));
}
public function getSubGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('sub_group'));
}
public function getHistoryGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::fromInterval(TimeEntryAggregationTypeInterval::from($this->input('history_group')));
}
public function getStart(): Carbon
{
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
}
public function getEnd(): Carbon
{
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
}
public function getFormatValue(): ExportFormat
{
return ExportFormat::from($this->validated('format'));
}
}

View File

@@ -95,7 +95,7 @@ class TimeEntryAggregateRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
// Filter by tag IDs, tag IDs are AND combined // Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [ 'tag_ids' => [
'array', 'array',
'min:1', 'min:1',

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
'format' => [
'required',
'string',
Rule::enum(ExportFormat::class),
],
// Filter by member ID
'member_id' => [
'string',
'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
'member_ids' => [
'array',
'min:1',
],
'member_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by project IDs, project IDs are OR combined
'project_ids' => [
'array',
'min:1',
],
'project_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [
'array',
'min:1',
],
'tag_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
'min:1',
],
'task_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [
'nullable',
'string',
'date_format:Y-m-d\TH:i:s\Z',
'before:end',
],
// Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'end' => [
'nullable',
'string',
'date_format:Y-m-d\TH:i:s\Z',
],
// Filter by active status (active means has no end date, is still running)
'active' => [
'string',
'in:true,false',
],
// Filter by billable status
'billable' => [
'string',
'in:true,false',
],
// Limit the number of returned time entries (default: 150)
'limit' => [
'integer',
'min:1',
'max:500',
],
// Filter makes sure that only time entries of a whole date are returned
'only_full_dates' => [
'string',
'in:true,false',
],
];
}
public function getOnlyFullDates(): bool
{
return $this->input('only_full_dates', 'false') === 'true';
}
public function getFormatValue(): ExportFormat
{
return ExportFormat::from($this->validated('format'));
}
}

View File

@@ -72,7 +72,7 @@ class TimeEntryIndexRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
// Filter by tag IDs, tag IDs are AND combined // Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [ 'tag_ids' => [
'array', 'array',
'min:1', 'min:1',

View File

@@ -7,11 +7,14 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\TagFactory; use Database\Factories\TagFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
/** /**
* @property string $id * @property string $id
@@ -19,6 +22,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $organization_id * @property string $organization_id
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Collection<TimeEntry> $timeEntries
* @property-read Organization $organization * @property-read Organization $organization
* *
* @method static TagFactory factory() * @method static TagFactory factory()
@@ -30,6 +34,7 @@ class Tag extends Model implements AuditableContract
/** @use HasFactory<TagFactory> */ /** @use HasFactory<TagFactory> */
use HasFactory; use HasFactory;
use HasJsonRelationships;
use HasUuids; use HasUuids;
/** /**
@@ -48,4 +53,14 @@ class Tag extends Model implements AuditableContract
{ {
return $this->belongsTo(Organization::class, 'organization_id'); return $this->belongsTo(Organization::class, 'organization_id');
} }
/**
* Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.
*
* @return HasManyJson<TimeEntry, $this>
*/
public function timeEntries(): HasManyJson
{
return $this->hasManyJson(TimeEntry::class, 'tags');
}
} }

View File

@@ -10,6 +10,7 @@ use App\Service\BillableRateService;
use Carbon\CarbonInterval; use Carbon\CarbonInterval;
use Database\Factories\TimeEntryFactory; use Database\Factories\TimeEntryFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -17,6 +18,8 @@ use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Korridor\LaravelComputedAttributes\ComputedAttributes; use Korridor\LaravelComputedAttributes\ComputedAttributes;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
/** /**
* @property string $id * @property string $id
@@ -42,6 +45,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property-read Client|null $client * @property-read Client|null $client
* @property string|null $task_id * @property string|null $task_id
* @property-read Task|null $task * @property-read Task|null $task
* @property-read Collection<Tag> $tagsRelation
* *
* @method Builder<TimeEntry> hasTag(Tag $tag) * @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory() * @method static TimeEntryFactory factory()
@@ -54,6 +58,7 @@ class TimeEntry extends Model implements AuditableContract
/** @use HasFactory<TimeEntryFactory> */ /** @use HasFactory<TimeEntryFactory> */
use HasFactory; use HasFactory;
use HasJsonRelationships;
use HasUuids; use HasUuids;
/** /**
@@ -197,4 +202,14 @@ class TimeEntry extends Model implements AuditableContract
{ {
return $this->belongsTo(Client::class, 'client_id'); return $this->belongsTo(Client::class, 'client_id');
} }
/**
* Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.
*
* @return BelongsToJson<Tag, $this>
*/
public function tagsRelation(): BelongsToJson
{
return $this->belongsToJson(Tag::class, 'tags');
}
} }

View File

@@ -25,7 +25,9 @@ use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto; use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams; use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode;
use Laravel\Passport\HasApiTokens; use Laravel\Passport\HasApiTokens;
use Laravel\Passport\Token;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -178,6 +180,22 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
return $this->hasMany(ProjectMember::class, 'user_id'); return $this->hasMany(ProjectMember::class, 'user_id');
} }
/**
* @return HasMany<Token>
*/
public function accessTokens(): HasMany
{
return $this->hasMany(Token::class);
}
/**
* @return HasMany<AuthCode>
*/
public function authCodes(): HasMany
{
return $this->hasMany(AuthCode::class);
}
/** /**
* @param Builder<User> $builder * @param Builder<User> $builder
*/ */

View File

@@ -28,7 +28,6 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@@ -91,9 +90,10 @@ class AppServiceProvider extends ServiceProvider
); );
}); });
if (config('app.force_https', false) || App::isProduction()) { if (config('app.force_https', false)) {
URL::forceScheme('https'); URL::forceScheme('https');
request()->server->set('HTTPS', request()->header('X-Forwarded-Proto', 'https') === 'https' ? 'on' : 'off'); request()->server->set('HTTPS', 'on');
request()->headers->set('X-Forwarded-Proto', 'https');
} }
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore { $this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {

View File

@@ -22,7 +22,7 @@ class BillingContract
*/ */
public function hasSubscription(Organization $organization): bool public function hasSubscription(Organization $organization): bool
{ {
return false; return true;
} }
/** /**

View File

@@ -47,22 +47,24 @@ class DashboardService
{ {
$result = []; $result = [];
$windowSize = 24 / $windows; $windowSize = 24 / $windows;
$end = Carbon::now($timeZone)->endOfDay()->subHours(3)->utc()->toDateTimeString(); $end = Carbon::now($timeZone)->startOfDay()->addDay()->subHours(3)->utc()->toDateTimeString();
$start = Carbon::now($timeZone)->subDays($days)->startOfDay()->utc()->toDateTimeString(); $start = Carbon::now($timeZone)->subDays($days)->startOfDay()->utc()->toDateTimeString();
$date = Carbon::now($timeZone)->startOfDay(); $date = Carbon::now($timeZone)->startOfDay();
$dateUtc = Carbon::now($timeZone)->startOfDay()->utc();
for ($i = 0; $i < $days; $i++) { for ($i = 0; $i < $days; $i++) {
$dateString = $date->format('Y-m-d'); $dateString = $date->format('Y-m-d');
$tempDate = $date->copy(); $tempDate = $dateUtc->copy();
$start = $tempDate->copy()->utc()->toDateTimeString(); $start = $tempDate->copy()->utc()->toDateTimeString();
$tempWindows = []; $tempWindows = [];
for ($j = 0; $j < $windows; $j++) { for ($j = 0; $j < $windows; $j++) {
$tempWindow = $tempDate->utc()->toDateTimeString(); $tempWindow = $tempDate->toDateTimeString();
$tempWindows[] = $tempWindow; $tempWindows[] = $tempWindow;
$tempDate->addHours($windowSize); $tempDate->addHours($windowSize);
} }
$result[$dateString] = $tempWindows; $result[$dateString] = $tempWindows;
$date->subDay(); $date->subDay();
$dateUtc->subDay();
} }
return [ return [

View File

@@ -144,6 +144,7 @@ class DeletionService
->get(); ->get();
foreach ($members as $member) { foreach ($members as $member) {
/** @var Member $member */
if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) { if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) {
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
} }
@@ -154,10 +155,13 @@ class DeletionService
if ($member->role === Role::Owner->value) { if ($member->role === Role::Owner->value) {
$this->deleteOrganization($member->organization, false, $user); $this->deleteOrganization($member->organization, false, $user);
} else { } else {
$this->memberService->makeMemberToPlaceholder($member); $this->memberService->makeMemberToPlaceholder($member, false);
} }
} }
$user->accessTokens()->delete();
$user->authCodes()->delete();
// Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last // Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last
$user->deleteProfilePhoto(); $user->deleteProfilePhoto();

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Carbon\CarbonInterval;
class IntervalService
{
public function format(CarbonInterval $interval): string
{
$interval->cascade();
return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S');
}
}

View File

@@ -44,7 +44,7 @@ class MemberService
} }
} }
public function makeMemberToPlaceholder(Member $member): void public function makeMemberToPlaceholder(Member $member, bool $makeSureUserHasAtLeastOneOrganization = true): void
{ {
$user = $member->user; $user = $member->user;
$placeholderUser = $user->replicate(); $placeholderUser = $user->replicate();
@@ -56,6 +56,8 @@ class MemberService
$member->save(); $member->save();
$this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member); $this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
$this->userService->makeSureUserHasAtLeastOneOrganization($user); if ($makeSureUserHasAtLeastOneOrganization) {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
}
} }
} }

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Service\ReportExport;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\File;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use League\Csv\Writer;
use Spatie\TemporaryDirectory\TemporaryDirectory;
/**
* @template T of Model
*/
abstract class CsvExport
{
private string $disk;
private string $filename;
private int $chunk;
/**
* @var string[]
*/
public const array HEADER = [];
/**
* @var Builder<T>
*/
private Builder $builder;
private string $folderPath;
protected const string CARBON_FORMAT = 'Y-m-d\TH:i:sP';
/**
* @param Builder<T> $builder
*/
public function __construct(string $disk, string $folderPath, string $filename, Builder $builder, int $chunk)
{
$this->disk = $disk;
$this->filename = $filename;
$this->chunk = $chunk;
$this->builder = $builder;
$this->folderPath = $folderPath;
}
/**
* @param T $model
* @return array<string, string|float|Carbon|null>
*/
abstract public function mapRow(Model $model): array;
/**
* @throws \League\Csv\CannotInsertRecord
* @throws \League\Csv\Exception
* @throws \League\Csv\UnavailableStream
*/
public function export(): void
{
$tempDirectory = TemporaryDirectory::make();
$writer = Writer::createFromPath($tempDirectory->path($this->filename), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne(static::HEADER);
$this->builder->chunk($this->chunk, function (Collection $models) use ($writer): void {
foreach ($models as $model) {
$data = $this->mapRow($model);
$row = $this->convertRow($data);
$this->validateRow($row);
$writer->insertOne(array_values($row));
}
});
Storage::disk($this->disk)->putFileAs($this->folderPath, new File($tempDirectory->path($this->filename)), $this->filename);
$tempDirectory->delete();
}
/**
* @param array<string, string|float|Carbon|null> $data
* @return array<string, string>
*/
private function convertRow(array $data): array
{
$convertedRow = [];
foreach ($data as $key => $value) {
if ($value instanceof Carbon) {
$convertedRow[$key] = $value->format(static::CARBON_FORMAT);
} elseif (is_float($value)) {
$convertedRow[$key] = (string) $value;
} elseif ($value === null) {
$convertedRow[$key] = '';
} else {
$convertedRow[$key] = $value;
}
}
return $convertedRow;
}
/**
* @param array<string, string> $row
*/
private function validateRow(array $row): void
{
if (array_keys($row) !== static::HEADER) {
throw new \LogicException('Invalid row');
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Models\TimeEntry;
use App\Service\IntervalService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @extends CsvExport<TimeEntry>
*/
class TimeEntriesDetailedCsvExport extends CsvExport
{
public const array HEADER = [
'Description',
'Task',
'Project',
'Client',
'User',
'Start',
'End',
'Duration',
'Duration (decimal)',
'Billable',
'Tags',
];
protected const string CARBON_FORMAT = 'Y-m-d H:i:s';
private string $timezone;
public function __construct(string $disk, string $folderPath, string $filename, Builder $builder, int $chunk, string $timezone)
{
parent::__construct($disk, $folderPath, $filename, $builder, $chunk);
$this->timezone = $timezone;
}
/**
* @param TimeEntry $model
*/
public function mapRow(Model $model): array
{
$interval = app(IntervalService::class);
$duration = $model->getDuration();
return [
'Description' => $model->description,
'Task' => $model->task?->name,
'Project' => $model->project?->name,
'Client' => $model->client?->name,
'User' => $model->user->name,
'Start' => $model->start->timezone($this->timezone),
'End' => $model->end->timezone($this->timezone),
'Duration' => $duration !== null ? $interval->format($model->getDuration()) : null,
'Duration (decimal)' => $duration?->totalHours,
'Billable' => $model->billable ? 'Yes' : 'No',
'Tags' => $model->tagsRelation->pluck('name')->implode(', '),
];
}
}

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Models\TimeEntry;
use App\Service\IntervalService;
use Illuminate\Database\Eloquent\Builder;
use LogicException;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Style\Style;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* @implements WithMapping<TimeEntry>
*/
class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithHeadings, WithMapping, WithStyles
{
use Exportable;
/**
* @var Builder<TimeEntry>
*/
private Builder $builder;
private ExportFormat $exportFormat;
private string $timezone;
/**
* @param Builder<TimeEntry> $builder
*/
public function __construct(Builder $builder, ExportFormat $exportFormat, string $timezone)
{
$this->builder = $builder;
$this->exportFormat = $exportFormat;
$this->timezone = $timezone;
}
/**
* @return Builder<TimeEntry>
*/
public function query(): Builder
{
return $this->builder;
}
/**
* @return array<string, string>
*/
public function columnFormats(): array
{
if ($this->exportFormat === ExportFormat::XLSX) {
return [
'F' => 'yyyy-mm-dd hh:mm:ss',
'G' => 'yyyy-mm-dd hh:mm:ss',
'I' => NumberFormat::FORMAT_NUMBER_00,
];
} elseif ($this->exportFormat === ExportFormat::ODS) {
return [
'I' => NumberFormat::FORMAT_NUMBER_00,
];
} else {
throw new LogicException('Unsupported export format.');
}
}
/**
* @return array<int|string, array<string, array<string, bool>>>
*/
public function styles(Worksheet $sheet): array
{
return [
// Style the first row as bold text.
1 => ['font' => ['bold' => true]],
];
}
/**
* @return string[]
*/
public function headings(): array
{
return [
'Description',
'Task',
'Project',
'Client',
'User',
'Start',
'End',
'Duration',
'Duration (decimal)',
'Billable',
'Tags',
];
}
/**
* @param TimeEntry $model
* @return array<int, string|float|null>
*/
public function map($model): array
{
$interval = app(IntervalService::class);
$duration = $model->getDuration();
if ($this->exportFormat === ExportFormat::XLSX) {
return [
$model->description,
$model->task?->name,
$model->project?->name,
$model->client?->name,
$model->user->name,
Date::dateTimeToExcel($model->start->timezone($this->timezone)),
$model->end !== null ? Date::dateTimeToExcel($model->end->timezone($this->timezone)) : null,
$duration !== null ? $interval->format($duration) : null,
$duration?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
} elseif ($this->exportFormat === ExportFormat::ODS) {
return [
$model->description,
$model->task?->name,
$model->project?->name,
$model->client?->name,
$model->user->name,
$model->start->timezone($this->timezone)->format('Y-m-d H:i:s'),
$model->end?->timezone($this->timezone)?->format('Y-m-d H:i:s'),
$duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null,
$duration?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
} else {
throw new LogicException('Unsupported export format.');
}
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use Illuminate\View\View;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromView;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithCustomCsvSettings;
class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsvSettings
{
use Exportable;
/**
* @var array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* }
*/
private array $data;
private ExportFormat $exportFormat;
private string $currency;
private TimeEntryAggregationType $group;
private TimeEntryAggregationType $subGroup;
/**
* @param array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* } $data
*/
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)
{
$this->data = $data;
$this->exportFormat = $exportFormat;
$this->currency = $currency;
$this->group = $group;
$this->subGroup = $subGroup;
}
public function view(): View
{
return view('reports.time-entry-aggregate-index-excel', [
'data' => $this->data,
'currency' => $this->currency,
'group' => $this->group,
'subGroup' => $this->subGroup,
'exportFormat' => $this->exportFormat,
]);
}
/**
* @return array<string, string>
*/
public function getCsvSettings(): array
{
return [
'delimiter' => ',',
'enclosure' => '"',
'escape_character' => '',
];
}
}

View File

@@ -7,7 +7,11 @@ namespace App\Service;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User;
use Carbon\CarbonTimeZone; use Carbon\CarbonTimeZone;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -135,6 +139,118 @@ class TimeEntryAggregationService
]; ];
} }
/**
* @param Builder<TimeEntry> $timeEntriesQuery
* @return array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* }
*/
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
{
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end);
$keysGroup1 = [];
$keysGroup2 = [];
if ($aggregatedTimeEntries['grouped_data'] !== null) {
foreach ($aggregatedTimeEntries['grouped_data'] as $group1) {
$keysGroup1[] = $group1['key'];
if ($group1['grouped_data'] !== null) {
foreach ($group1['grouped_data'] as $group2) {
$keysGroup2[] = $group2['key'];
}
}
}
}
$descriptionMapGroup1 = $group1Type !== null ? $this->loadDescriptionMap($keysGroup1, $group1Type) : [];
$descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptionMap($keysGroup2, $group2Type) : [];
if ($aggregatedTimeEntries['grouped_data'] !== null) {
foreach ($aggregatedTimeEntries['grouped_data'] as $keyGroup1 => $group1) {
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']] ?? null) : null;
if ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] !== null) {
foreach ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] as $keyGroup2 => $group2) {
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']] ?? null) : null;
}
}
}
}
/**
* @var array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* } $aggregatedTimeEntries
*/
return $aggregatedTimeEntries;
}
/**
* @param array<int, string> $keys
* @return array<string, string>
*/
private function loadDescriptionMap(array $keys, TimeEntryAggregationType $type): array
{
if ($type === TimeEntryAggregationType::Client) {
return Client::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
} elseif ($type === TimeEntryAggregationType::User) {
return User::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
} elseif ($type === TimeEntryAggregationType::Project) {
return Project::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
} elseif ($type === TimeEntryAggregationType::Task) {
return Task::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
} else {
return [];
}
}
/** /**
* @param array<array{ * @param array<array{
* key: string|null, * key: string|null,

View File

@@ -133,7 +133,11 @@ class TimeEntryFilter
if ($tagIds === null) { if ($tagIds === null) {
return $this; return $this;
} }
$this->builder->whereJsonContains('tags', $tagIds); $this->builder->where(function (Builder $builder) use ($tagIds): void {
foreach ($tagIds as $tagId) {
$builder->orWhereJsonContains('tags', $tagId);
}
});
return $this; return $this;
} }

View File

@@ -8,9 +8,11 @@
"php": "8.3.*", "php": "8.3.*",
"ext-zip": "*", "ext-zip": "*",
"brick/money": "^0.9.0", "brick/money": "^0.9.0",
"datomatic/laravel-enum-helper": "^1.1",
"dedoc/scramble": "dev-main", "dedoc/scramble": "dev-main",
"filament/filament": "^3.2", "filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.2.0", "flowframe/laravel-trend": "^0.2.0",
"gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^1.0", "inertiajs/inertia-laravel": "^1.0",
"korridor/laravel-computed-attributes": "^3.1", "korridor/laravel-computed-attributes": "^3.1",
@@ -20,12 +22,15 @@
"laravel/octane": "^2.3", "laravel/octane": "^2.3",
"laravel/passport": "^12.0", "laravel/passport": "^12.0",
"laravel/tinker": "^2.8", "laravel/tinker": "^2.8",
"league/csv": "^9.16.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"maatwebsite/excel": "^3.1",
"novadaemon/filament-pretty-json": "^2.2", "novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^11.0.11", "nwidart/laravel-modules": "^11.0.11",
"owen-it/laravel-auditing": "^13.6", "owen-it/laravel-auditing": "^13.6",
"pxlrbt/filament-environment-indicator": "^2.0", "pxlrbt/filament-environment-indicator": "^2.0",
"spatie/temporary-directory": "^2.2", "spatie/temporary-directory": "^2.2",
"staudenmeir/eloquent-json-relations": "^1.1",
"stechstudio/filament-impersonate": "^3.8", "stechstudio/filament-impersonate": "^3.8",
"tightenco/ziggy": "^2.1.0", "tightenco/ziggy": "^2.1.0",
"tpetry/laravel-postgresql-enhanced": "^2.0.0", "tpetry/laravel-postgresql-enhanced": "^2.0.0",

933
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "84e9d436af2f46e57ecc42a117e94259", "content-hash": "0ae21e521922b7431905a0764dfc0245",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -1408,6 +1408,113 @@
}, },
"time": "2024-08-09T14:30:48+00:00" "time": "2024-08-09T14:30:48+00:00"
}, },
{
"name": "datomatic/enum-helper",
"version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/datomatic/enum-helper.git",
"reference": "a31986b4a2876e5d942da2816f760d829af52664"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/datomatic/enum-helper/zipball/a31986b4a2876e5d942da2816f760d829af52664",
"reference": "a31986b4a2876e5d942da2816f760d829af52664",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-mbstring": "*",
"php": "^8.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.8",
"pestphp/pest": "^1.21",
"phpstan/phpstan": "^1.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Datomatic\\EnumHelper\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alberto Peripolli",
"email": "info@albertoperipolli.com"
}
],
"description": "Simple opinionated framework agnostic PHP 8.1 enum helper",
"support": {
"issues": "https://github.com/datomatic/enum-helper/issues",
"source": "https://github.com/datomatic/enum-helper/tree/v1.1.0"
},
"time": "2022-10-15T11:27:54+00:00"
},
{
"name": "datomatic/laravel-enum-helper",
"version": "v1.1.1",
"source": {
"type": "git",
"url": "https://github.com/datomatic/laravel-enum-helper.git",
"reference": "293fd7d454b3718b5046a08913e4b121552124a9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/datomatic/laravel-enum-helper/zipball/293fd7d454b3718b5046a08913e4b121552124a9",
"reference": "293fd7d454b3718b5046a08913e4b121552124a9",
"shasum": ""
},
"require": {
"composer/class-map-generator": "^1.0",
"datomatic/enum-helper": "^1.0",
"illuminate/support": "^8.0|^9.0|^10.0|^11.0",
"illuminate/translation": "^8.0|^9.0|^10.0|^11.0",
"jawira/case-converter": "^3.5",
"laminas/laminas-code": "^4.0",
"php": "^8.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.8",
"orchestra/testbench": "^6.23|^7.0|^9.0",
"pestphp/pest": "^1.21|^2.34",
"pestphp/pest-plugin-laravel": "^1.2|^2.3",
"phpstan/phpstan": "^1.7"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Datomatic\\LaravelEnumHelper\\LaravelEnumHelperServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Datomatic\\LaravelEnumHelper\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alberto Peripolli",
"email": "info@albertoperipolli.com"
}
],
"description": "Simple opinionated framework agnostic PHP 8.1 enum helper for Laravel",
"support": {
"issues": "https://github.com/datomatic/laravel-enum-helper/issues",
"source": "https://github.com/datomatic/laravel-enum-helper/tree/v1.1.1"
},
"time": "2024-03-14T14:36:39+00:00"
},
{ {
"name": "dedoc/scramble", "name": "dedoc/scramble",
"version": "dev-main", "version": "dev-main",
@@ -2093,6 +2200,67 @@
], ],
"time": "2023-10-06T06:47:41+00:00" "time": "2023-10-06T06:47:41+00:00"
}, },
{
"name": "ezyang/htmlpurifier",
"version": "v4.17.0",
"source": {
"type": "git",
"url": "https://github.com/ezyang/htmlpurifier.git",
"reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/bbc513d79acf6691fa9cf10f192c90dd2957f18c",
"reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c",
"shasum": ""
},
"require": {
"php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0"
},
"require-dev": {
"cerdic/css-tidy": "^1.7 || ^2.0",
"simpletest/simpletest": "dev-master"
},
"suggest": {
"cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
"ext-bcmath": "Used for unit conversion and imagecrash protection",
"ext-iconv": "Converts text to and from non-UTF-8 encodings",
"ext-tidy": "Used for pretty-printing HTML"
},
"type": "library",
"autoload": {
"files": [
"library/HTMLPurifier.composer.php"
],
"psr-0": {
"HTMLPurifier": "library/"
},
"exclude-from-classmap": [
"/library/HTMLPurifier/Language/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "Edward Z. Yang",
"email": "admin@htmlpurifier.org",
"homepage": "http://ezyang.com"
}
],
"description": "Standards compliant HTML filter written in PHP",
"homepage": "http://htmlpurifier.org/",
"keywords": [
"html"
],
"support": {
"issues": "https://github.com/ezyang/htmlpurifier/issues",
"source": "https://github.com/ezyang/htmlpurifier/tree/v4.17.0"
},
"time": "2023-11-17T15:01:25+00:00"
},
{ {
"name": "filament/actions", "name": "filament/actions",
"version": "v3.2.115", "version": "v3.2.115",
@@ -2733,6 +2901,87 @@
], ],
"time": "2023-10-12T05:21:21+00:00" "time": "2023-10-12T05:21:21+00:00"
}, },
{
"name": "gotenberg/gotenberg-php",
"version": "v2.8.0",
"source": {
"type": "git",
"url": "https://github.com/gotenberg/gotenberg-php.git",
"reference": "e8bc519812349f7bd57b294b4ea41dce53693b7c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/gotenberg/gotenberg-php/zipball/e8bc519812349f7bd57b294b4ea41dce53693b7c",
"reference": "e8bc519812349f7bd57b294b4ea41dce53693b7c",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"guzzlehttp/psr7": "^1 || ^2.1",
"php": "^8.1|^8.2|^8.3",
"php-http/discovery": "^1.14",
"psr/http-client": "^1.0",
"psr/http-message": "^1.0|^2.0"
},
"require-dev": {
"doctrine/coding-standard": "^12.0",
"pestphp/pest": "^2.28",
"phpstan/phpstan": "^1.12",
"squizlabs/php_codesniffer": "^3.10"
},
"type": "library",
"autoload": {
"psr-4": {
"Gotenberg\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Julien Neuhart",
"email": "neuhart.julien@gmail.com",
"homepage": "https://github.com/gulien",
"role": "Developer"
}
],
"description": "A PHP client for interacting with Gotenberg, a developer-friendly API for converting numerous document formats into PDF files, and more!",
"homepage": "https://github.com/gotenberg/gotenberg-php",
"keywords": [
"Gotenberg",
"LibreOffice",
"chrome",
"chromium",
"convert",
"csv",
"docx",
"excel",
"html",
"markdown",
"pdf",
"pdftk",
"pptx",
"puppeteer",
"unoconv",
"wkhtmltopdf",
"word",
"xlsx"
],
"support": {
"issues": "https://github.com/gotenberg/gotenberg-php/issues",
"source": "https://github.com/gotenberg/gotenberg-php/tree/v2.8.0"
},
"funding": [
{
"url": "https://github.com/gulien",
"type": "github"
}
],
"time": "2024-09-29T17:23:42+00:00"
},
{ {
"name": "graham-campbell/result-type", "name": "graham-campbell/result-type",
"version": "v1.1.3", "version": "v1.1.3",
@@ -3282,6 +3531,73 @@
], ],
"time": "2024-06-13T01:25:09+00:00" "time": "2024-06-13T01:25:09+00:00"
}, },
{
"name": "jawira/case-converter",
"version": "v3.5.1",
"source": {
"type": "git",
"url": "https://github.com/jawira/case-converter.git",
"reference": "2be05b98dcb743bef60ab6f849145bd3434ed003"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jawira/case-converter/zipball/2be05b98dcb743bef60ab6f849145bd3434ed003",
"reference": "2be05b98dcb743bef60ab6f849145bd3434ed003",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": ">=7.4"
},
"require-dev": {
"behat/behat": "^3.0",
"phpstan/phpstan": "^1.0",
"phpunit/phpunit": "^9.0",
"vimeo/psalm": "^4.0"
},
"suggest": {
"pds/skeleton": "PHP Package Development Standards",
"phing/phing": "PHP Build Tool"
},
"type": "library",
"autoload": {
"psr-4": {
"Jawira\\CaseConverter\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jawira Portugal",
"email": "dev@tugal.be"
}
],
"description": "Convert strings between 13 naming conventions: Snake case, Camel case, Pascal case, Kebab case, Ada case, Train case, Cobol case, Macro case, Upper case, Lower case, Sentence case, Title case and Dot notation.",
"homepage": "https://jawira.github.io/case-converter/",
"keywords": [
"Ada case",
"Cobol case",
"Macro case",
"Train case",
"camel case",
"dot notation",
"kebab case",
"lower case",
"pascal case",
"sentence case",
"snake case",
"title case",
"upper case"
],
"support": {
"issues": "https://github.com/jawira/case-converter/issues",
"source": "https://github.com/jawira/case-converter/tree/v3.5.1"
},
"time": "2022-08-14T11:40:18+00:00"
},
{ {
"name": "justinrainbow/json-schema", "name": "justinrainbow/json-schema",
"version": "5.3.0", "version": "5.3.0",
@@ -3609,6 +3925,69 @@
}, },
"time": "2024-03-11T14:26:14+00:00" "time": "2024-03-11T14:26:14+00:00"
}, },
{
"name": "laminas/laminas-code",
"version": "4.14.0",
"source": {
"type": "git",
"url": "https://github.com/laminas/laminas-code.git",
"reference": "562e02b7d85cb9142b5116cc76c4c7c162a11a1c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laminas/laminas-code/zipball/562e02b7d85cb9142b5116cc76c4c7c162a11a1c",
"reference": "562e02b7d85cb9142b5116cc76c4c7c162a11a1c",
"shasum": ""
},
"require": {
"php": "~8.1.0 || ~8.2.0 || ~8.3.0"
},
"require-dev": {
"doctrine/annotations": "^2.0.1",
"ext-phar": "*",
"laminas/laminas-coding-standard": "^2.5.0",
"laminas/laminas-stdlib": "^3.17.0",
"phpunit/phpunit": "^10.3.3",
"psalm/plugin-phpunit": "^0.19.0",
"vimeo/psalm": "^5.15.0"
},
"suggest": {
"doctrine/annotations": "Doctrine\\Common\\Annotations >=1.0 for annotation features",
"laminas/laminas-stdlib": "Laminas\\Stdlib component"
},
"type": "library",
"autoload": {
"psr-4": {
"Laminas\\Code\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "Extensions to the PHP Reflection API, static code scanning, and code generation",
"homepage": "https://laminas.dev",
"keywords": [
"code",
"laminas",
"laminasframework"
],
"support": {
"chat": "https://laminas.dev/chat",
"docs": "https://docs.laminas.dev/laminas-code/",
"forum": "https://discourse.laminas.dev",
"issues": "https://github.com/laminas/laminas-code/issues",
"rss": "https://github.com/laminas/laminas-code/releases.atom",
"source": "https://github.com/laminas/laminas-code"
},
"funding": [
{
"url": "https://funding.communitybridge.org/projects/laminas-project",
"type": "community_bridge"
}
],
"time": "2024-06-17T08:50:25+00:00"
},
{ {
"name": "laminas/laminas-diactoros", "name": "laminas/laminas-diactoros",
"version": "3.4.0", "version": "3.4.0",
@@ -5435,6 +5814,271 @@
], ],
"time": "2024-07-15T18:27:32+00:00" "time": "2024-07-15T18:27:32+00:00"
}, },
{
"name": "maatwebsite/excel",
"version": "3.1.58",
"source": {
"type": "git",
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
"reference": "18495a71b112f43af8ffab35111a58b4e4ba4a4d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/18495a71b112f43af8ffab35111a58b4e4ba4a4d",
"reference": "18495a71b112f43af8ffab35111a58b4e4ba4a4d",
"shasum": ""
},
"require": {
"composer/semver": "^3.3",
"ext-json": "*",
"illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0",
"php": "^7.0||^8.0",
"phpoffice/phpspreadsheet": "^1.29.1",
"psr/simple-cache": "^1.0||^2.0||^3.0"
},
"require-dev": {
"laravel/scout": "^7.0||^8.0||^9.0||^10.0",
"orchestra/testbench": "^6.0||^7.0||^8.0||^9.0",
"predis/predis": "^1.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Maatwebsite\\Excel\\ExcelServiceProvider"
],
"aliases": {
"Excel": "Maatwebsite\\Excel\\Facades\\Excel"
}
}
},
"autoload": {
"psr-4": {
"Maatwebsite\\Excel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Patrick Brouwers",
"email": "patrick@spartner.nl"
}
],
"description": "Supercharged Excel exports and imports in Laravel",
"keywords": [
"PHPExcel",
"batch",
"csv",
"excel",
"export",
"import",
"laravel",
"php",
"phpspreadsheet"
],
"support": {
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.58"
},
"funding": [
{
"url": "https://laravel-excel.com/commercial-support",
"type": "custom"
},
{
"url": "https://github.com/patrickbrouwers",
"type": "github"
}
],
"time": "2024-09-07T13:53:36+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.1.1",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "6187e9cc4493da94b9b63eb2315821552015fca9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/6187e9cc4493da94b9b63eb2315821552015fca9",
"reference": "6187e9cc4493da94b9b63eb2315821552015fca9",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.1"
},
"require-dev": {
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.16",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^10.0",
"vimeo/psalm": "^5.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.1"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2024-10-10T12:33:01+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{ {
"name": "masterminds/html5", "name": "masterminds/html5",
"version": "2.9.0", "version": "2.9.0",
@@ -6670,6 +7314,190 @@
}, },
"time": "2020-10-15T08:29:30+00:00" "time": "2020-10-15T08:29:30+00:00"
}, },
{
"name": "php-http/discovery",
"version": "1.20.0",
"source": {
"type": "git",
"url": "https://github.com/php-http/discovery.git",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
"shasum": ""
},
"require": {
"composer-plugin-api": "^1.0|^2.0",
"php": "^7.1 || ^8.0"
},
"conflict": {
"nyholm/psr7": "<1.0",
"zendframework/zend-diactoros": "*"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "*",
"psr/http-factory-implementation": "*",
"psr/http-message-implementation": "*"
},
"require-dev": {
"composer/composer": "^1.0.2|^2.0",
"graham-campbell/phpspec-skip-example-extension": "^5.0",
"php-http/httplug": "^1.0 || ^2.0",
"php-http/message-factory": "^1.0",
"phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
"sebastian/comparator": "^3.0.5 || ^4.0.8",
"symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
},
"type": "composer-plugin",
"extra": {
"class": "Http\\Discovery\\Composer\\Plugin",
"plugin-optional": true
},
"autoload": {
"psr-4": {
"Http\\Discovery\\": "src/"
},
"exclude-from-classmap": [
"src/Composer/Plugin.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
"homepage": "http://php-http.org",
"keywords": [
"adapter",
"client",
"discovery",
"factory",
"http",
"message",
"psr17",
"psr7"
],
"support": {
"issues": "https://github.com/php-http/discovery/issues",
"source": "https://github.com/php-http/discovery/tree/1.20.0"
},
"time": "2024-10-02T11:20:13+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "1.29.2",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "3a5a818d7d3e4b5bd2e56fb9de44dbded6eae07f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/3a5a818d7d3e4b5bd2e56fb9de44dbded6eae07f",
"reference": "3a5a818d7d3e4b5bd2e56fb9de44dbded6eae07f",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"ezyang/htmlpurifier": "^4.15",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^7.4 || ^8.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^1.0 || ^2.0",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.3",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1",
"phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^8.5 || ^9.0",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.29.2"
},
"time": "2024-09-29T07:04:47+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.3", "version": "1.9.3",
@@ -8407,6 +9235,109 @@
], ],
"time": "2023-12-25T11:46:58+00:00" "time": "2023-12-25T11:46:58+00:00"
}, },
{
"name": "staudenmeir/eloquent-has-many-deep-contracts",
"version": "v1.2.1",
"source": {
"type": "git",
"url": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts.git",
"reference": "3ad76c6eeda60042f262d113bf471dcce584d88b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/staudenmeir/eloquent-has-many-deep-contracts/zipball/3ad76c6eeda60042f262d113bf471dcce584d88b",
"reference": "3ad76c6eeda60042f262d113bf471dcce584d88b",
"shasum": ""
},
"require": {
"illuminate/database": "^11.0",
"php": "^8.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Staudenmeir\\EloquentHasManyDeepContracts\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonas Staudenmeir",
"email": "mail@jonas-staudenmeir.de"
}
],
"description": "Contracts for staudenmeir/eloquent-has-many-deep",
"support": {
"issues": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts/issues",
"source": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts/tree/v1.2.1"
},
"time": "2024-09-25T18:24:22+00:00"
},
{
"name": "staudenmeir/eloquent-json-relations",
"version": "v1.13.1",
"source": {
"type": "git",
"url": "https://github.com/staudenmeir/eloquent-json-relations.git",
"reference": "65533e304061ee649c0bcfd0e0da9376712e8b0e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/staudenmeir/eloquent-json-relations/zipball/65533e304061ee649c0bcfd0e0da9376712e8b0e",
"reference": "65533e304061ee649c0bcfd0e0da9376712e8b0e",
"shasum": ""
},
"require": {
"illuminate/database": "^11.0",
"php": "^8.2",
"staudenmeir/eloquent-has-many-deep-contracts": "^1.2"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^3.0",
"larastan/larastan": "^2.9",
"orchestra/testbench": "^9.0",
"phpunit/phpunit": "^11.0",
"staudenmeir/eloquent-has-many-deep": "^1.20"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Staudenmeir\\EloquentJsonRelations\\IdeHelperServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Staudenmeir\\EloquentJsonRelations\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonas Staudenmeir",
"email": "mail@jonas-staudenmeir.de"
}
],
"description": "Laravel Eloquent relationships with JSON keys",
"support": {
"issues": "https://github.com/staudenmeir/eloquent-json-relations/issues",
"source": "https://github.com/staudenmeir/eloquent-json-relations/tree/v1.13.1"
},
"funding": [
{
"url": "https://paypal.me/JonasStaudenmeir",
"type": "custom"
}
],
"time": "2024-10-06T19:12:12+00:00"
},
{ {
"name": "stechstudio/filament-impersonate", "name": "stechstudio/filament-impersonate",
"version": "3.14", "version": "3.14",

382
config/excel.php Normal file
View File

@@ -0,0 +1,382 @@
<?php
declare(strict_types=1);
use Maatwebsite\Excel\Excel;
use PhpOffice\PhpSpreadsheet\Reader\Csv;
return [
'exports' => [
/*
|--------------------------------------------------------------------------
| Chunk size
|--------------------------------------------------------------------------
|
| When using FromQuery, the query is automatically chunked.
| Here you can specify how big the chunk should be.
|
*/
'chunk_size' => 1000,
/*
|--------------------------------------------------------------------------
| Pre-calculate formulas during export
|--------------------------------------------------------------------------
*/
'pre_calculate_formulas' => false,
/*
|--------------------------------------------------------------------------
| Enable strict null comparison
|--------------------------------------------------------------------------
|
| When enabling strict null comparison empty cells ('') will
| be added to the sheet.
*/
'strict_null_comparison' => false,
/*
|--------------------------------------------------------------------------
| CSV Settings
|--------------------------------------------------------------------------
|
| Configure e.g. delimiter, enclosure and line ending for CSV exports.
|
*/
'csv' => [
'delimiter' => ',',
'enclosure' => '"',
'line_ending' => PHP_EOL,
'use_bom' => false,
'include_separator_line' => false,
'excel_compatibility' => false,
'output_encoding' => '',
'test_auto_detect' => true,
],
/*
|--------------------------------------------------------------------------
| Worksheet properties
|--------------------------------------------------------------------------
|
| Configure e.g. default title, creator, subject,...
|
*/
'properties' => [
'creator' => '',
'lastModifiedBy' => '',
'title' => '',
'description' => '',
'subject' => '',
'keywords' => '',
'category' => '',
'manager' => '',
'company' => '',
],
],
'imports' => [
/*
|--------------------------------------------------------------------------
| Read Only
|--------------------------------------------------------------------------
|
| When dealing with imports, you might only be interested in the
| data that the sheet exists. By default we ignore all styles,
| however if you want to do some logic based on style data
| you can enable it by setting read_only to false.
|
*/
'read_only' => true,
/*
|--------------------------------------------------------------------------
| Ignore Empty
|--------------------------------------------------------------------------
|
| When dealing with imports, you might be interested in ignoring
| rows that have null values or empty strings. By default rows
| containing empty strings or empty values are not ignored but can be
| ignored by enabling the setting ignore_empty to true.
|
*/
'ignore_empty' => false,
/*
|--------------------------------------------------------------------------
| Heading Row Formatter
|--------------------------------------------------------------------------
|
| Configure the heading row formatter.
| Available options: none|slug|custom
|
*/
'heading_row' => [
'formatter' => 'slug',
],
/*
|--------------------------------------------------------------------------
| CSV Settings
|--------------------------------------------------------------------------
|
| Configure e.g. delimiter, enclosure and line ending for CSV imports.
|
*/
'csv' => [
'delimiter' => null,
'enclosure' => '"',
'escape_character' => '\\',
'contiguous' => false,
'input_encoding' => Csv::GUESS_ENCODING,
],
/*
|--------------------------------------------------------------------------
| Worksheet properties
|--------------------------------------------------------------------------
|
| Configure e.g. default title, creator, subject,...
|
*/
'properties' => [
'creator' => '',
'lastModifiedBy' => '',
'title' => '',
'description' => '',
'subject' => '',
'keywords' => '',
'category' => '',
'manager' => '',
'company' => '',
],
/*
|--------------------------------------------------------------------------
| Cell Middleware
|--------------------------------------------------------------------------
|
| Configure middleware that is executed on getting a cell value
|
*/
'cells' => [
'middleware' => [
//\Maatwebsite\Excel\Middleware\TrimCellValue::class,
//\Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class,
],
],
],
/*
|--------------------------------------------------------------------------
| Extension detector
|--------------------------------------------------------------------------
|
| Configure here which writer/reader type should be used when the package
| needs to guess the correct type based on the extension alone.
|
*/
'extension_detector' => [
'xlsx' => Excel::XLSX,
'xlsm' => Excel::XLSX,
'xltx' => Excel::XLSX,
'xltm' => Excel::XLSX,
'xls' => Excel::XLS,
'xlt' => Excel::XLS,
'ods' => Excel::ODS,
'ots' => Excel::ODS,
'slk' => Excel::SLK,
'xml' => Excel::XML,
'gnumeric' => Excel::GNUMERIC,
'htm' => Excel::HTML,
'html' => Excel::HTML,
'csv' => Excel::CSV,
'tsv' => Excel::TSV,
/*
|--------------------------------------------------------------------------
| PDF Extension
|--------------------------------------------------------------------------
|
| Configure here which Pdf driver should be used by default.
| Available options: Excel::MPDF | Excel::TCPDF | Excel::DOMPDF
|
*/
'pdf' => Excel::DOMPDF,
],
/*
|--------------------------------------------------------------------------
| Value Binder
|--------------------------------------------------------------------------
|
| PhpSpreadsheet offers a way to hook into the process of a value being
| written to a cell. In there some assumptions are made on how the
| value should be formatted. If you want to change those defaults,
| you can implement your own default value binder.
|
| Possible value binders:
|
| [x] Maatwebsite\Excel\DefaultValueBinder::class
| [x] PhpOffice\PhpSpreadsheet\Cell\StringValueBinder::class
| [x] PhpOffice\PhpSpreadsheet\Cell\AdvancedValueBinder::class
|
*/
'value_binder' => [
'default' => Maatwebsite\Excel\DefaultValueBinder::class,
],
'cache' => [
/*
|--------------------------------------------------------------------------
| Default cell caching driver
|--------------------------------------------------------------------------
|
| By default PhpSpreadsheet keeps all cell values in memory, however when
| dealing with large files, this might result into memory issues. If you
| want to mitigate that, you can configure a cell caching driver here.
| When using the illuminate driver, it will store each value in the
| cache store. This can slow down the process, because it needs to
| store each value. You can use the "batch" store if you want to
| only persist to the store when the memory limit is reached.
|
| Drivers: memory|illuminate|batch
|
*/
'driver' => 'memory',
/*
|--------------------------------------------------------------------------
| Batch memory caching
|--------------------------------------------------------------------------
|
| When dealing with the "batch" caching driver, it will only
| persist to the store when the memory limit is reached.
| Here you can tweak the memory limit to your liking.
|
*/
'batch' => [
'memory_limit' => 60000,
],
/*
|--------------------------------------------------------------------------
| Illuminate cache
|--------------------------------------------------------------------------
|
| When using the "illuminate" caching driver, it will automatically use
| your default cache store. However if you prefer to have the cell
| cache on a separate store, you can configure the store name here.
| You can use any store defined in your cache config. When leaving
| at "null" it will use the default store.
|
*/
'illuminate' => [
'store' => null,
],
/*
|--------------------------------------------------------------------------
| Cache Time-to-live (TTL)
|--------------------------------------------------------------------------
|
| The TTL of items written to cache. If you want to keep the items cached
| indefinitely, set this to null. Otherwise, set a number of seconds,
| a \DateInterval, or a callable.
|
| Allowable types: callable|\DateInterval|int|null
|
*/
'default_ttl' => 10800,
],
/*
|--------------------------------------------------------------------------
| Transaction Handler
|--------------------------------------------------------------------------
|
| By default the import is wrapped in a transaction. This is useful
| for when an import may fail and you want to retry it. With the
| transactions, the previous import gets rolled-back.
|
| You can disable the transaction handler by setting this to null.
| Or you can choose a custom made transaction handler here.
|
| Supported handlers: null|db
|
*/
'transactions' => [
'handler' => 'db',
'db' => [
'connection' => null,
],
],
'temporary_files' => [
/*
|--------------------------------------------------------------------------
| Local Temporary Path
|--------------------------------------------------------------------------
|
| When exporting and importing files, we use a temporary file, before
| storing reading or downloading. Here you can customize that path.
| permissions is an array with the permission flags for the directory (dir)
| and the create file (file).
|
*/
'local_path' => storage_path('framework/cache/laravel-excel'),
/*
|--------------------------------------------------------------------------
| Local Temporary Path Permissions
|--------------------------------------------------------------------------
|
| Permissions is an array with the permission flags for the directory (dir)
| and the create file (file).
| If omitted the default permissions of the filesystem will be used.
|
*/
'local_permissions' => [
// 'dir' => 0755,
// 'file' => 0644,
],
/*
|--------------------------------------------------------------------------
| Remote Temporary Disk
|--------------------------------------------------------------------------
|
| When dealing with a multi server setup with queues in which you
| cannot rely on having a shared local temporary path, you might
| want to store the temporary file on a shared disk. During the
| queue executing, we'll retrieve the temporary file from that
| location instead. When left to null, it will always use
| the local path. This setting only has effect when using
| in conjunction with queued imports and exports.
|
*/
'remote_disk' => null,
'remote_prefix' => null,
/*
|--------------------------------------------------------------------------
| Force Resync
|--------------------------------------------------------------------------
|
| When dealing with a multi server setup as above, it's possible
| for the clean up that occurs after entire queue has been run to only
| cleanup the server that the last AfterImportJob runs on. The rest of the server
| would still have the local temporary file stored on it. In this case your
| local storage limits can be exceeded and future imports won't be processed.
| To mitigate this you can set this config value to be true, so that after every
| queued chunk is processed the local temporary file is deleted on the server that
| processed it.
|
*/
'force_resync_remote' => null,
],
];

11
config/services.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
return [
'gotenberg' => [
'url' => env('GOTENBERG_URL'),
'basic_auth_username' => env('GOTENBERG_BASIC_AUTH_USERNAME'),
'basic_auth_password' => env('GOTENBERG_BASIC_AUTH_PASSWORD'),
],
];

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$foreignKeyProblems = DB::table('organizations')
->select(['organizations.id', 'organizations.user_id'])
->whereNotExists(function (Builder $query): void {
$query->select('id')
->from('users')
->whereColumn('organizations.user_id', 'users.id');
})
->get();
foreach ($foreignKeyProblems as $foreignKeyProblem) {
Log::error('Organization with ID '.$foreignKeyProblem->id.' has non-existing owner with ID '.$foreignKeyProblem->user_id);
}
if ($foreignKeyProblems->count() > 0) {
throw new Exception('There are organizations with non-existing owners, check the logs for more information');
}
$foreignKeyProblems = DB::table('members')
->select(['members.id', 'members.organization_id'])
->whereNotExists(function (Builder $query): void {
$query->select('id')
->from('organizations')
->whereColumn('members.organization_id', 'organizations.id');
})
->get();
foreach ($foreignKeyProblems as $foreignKeyProblem) {
Log::error('Member with ID '.$foreignKeyProblem->id.' has non-existing organization with ID '.$foreignKeyProblem->organization_id);
}
if ($foreignKeyProblems->count() > 0) {
throw new Exception('There are members with non-existing organizations, check the logs for more information');
}
$foreignKeyProblems = DB::table('members')
->select(['members.id', 'members.user_id'])
->whereNotExists(function (Builder $query): void {
$query->select('id')
->from('users')
->whereColumn('members.user_id', 'users.id');
})
->get();
foreach ($foreignKeyProblems as $foreignKeyProblem) {
Log::error('Member with ID '.$foreignKeyProblem->id.' has non-existing user with ID '.$foreignKeyProblem->user_id);
}
if ($foreignKeyProblems->count() > 0) {
throw new Exception('There are members with non-existing users, check the logs for more information');
}
Schema::table('organizations', function (Blueprint $table): void {
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('restrict')
->onUpdate('cascade');
});
Schema::table('members', function (Blueprint $table): void {
$table->foreign('organization_id')
->references('id')
->on('organizations')
->onDelete('restrict')
->onUpdate('cascade');
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('restrict')
->onUpdate('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropForeign(['user_id']);
});
Schema::table('members', function (Blueprint $table): void {
$table->dropForeign(['organization_id']);
$table->dropForeign(['user_id']);
});
}
};

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('oauth_access_tokens')
->whereNotNull('user_id')
->whereNotExists(function (Builder $query): void {
$query->select('id')
->from('users')
->whereColumn('oauth_access_tokens.user_id', 'users.id');
})
->delete();
DB::table('oauth_access_tokens')
->whereNotExists(function (Builder $query): void {
$query->select('id')
->from('oauth_clients')
->whereColumn('oauth_access_tokens.client_id', 'oauth_clients.id');
})
->delete();
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('restrict')
->onUpdate('cascade');
$table->foreign('client_id')
->references('id')
->on('oauth_clients')
->onDelete('restrict')
->onUpdate('cascade');
});
DB::table('oauth_auth_codes')
->whereNotExists(function (Builder $query): void {
$query->select('id')
->from('users')
->whereColumn('oauth_auth_codes.user_id', 'users.id');
})
->delete();
DB::table('oauth_auth_codes')
->whereNotExists(function (Builder $query): void {
$query->select('id')
->from('oauth_clients')
->whereColumn('oauth_auth_codes.client_id', 'oauth_clients.id');
})
->delete();
Schema::table('oauth_auth_codes', function (Blueprint $table): void {
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('restrict')
->onUpdate('cascade');
$table->foreign('client_id')
->references('id')
->on('oauth_clients')
->onDelete('restrict')
->onUpdate('cascade');
});
DB::table('oauth_clients')
->whereNotNull('user_id')
->whereNotExists(function (Builder $query): void {
$query->select('id')
->from('users')
->whereColumn('oauth_clients.user_id', 'users.id');
})
->delete();
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('restrict')
->onUpdate('cascade');
});
Schema::table('oauth_personal_access_clients', function (Blueprint $table): void {
$table->foreign('client_id')
->references('id')
->on('oauth_clients')
->onDelete('restrict')
->onUpdate('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
$table->dropForeign(['user_id']);
$table->dropForeign(['client_id']);
});
Schema::table('oauth_auth_codes', function (Blueprint $table): void {
$table->dropForeign(['user_id']);
$table->dropForeign(['client_id']);
});
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->dropForeign(['user_id']);
});
Schema::table('oauth_personal_access_clients', function (Blueprint $table): void {
$table->dropForeign(['client_id']);
});
}
};

View File

@@ -18,6 +18,12 @@ use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Laravel\Passport\AuthCode;
use Laravel\Passport\Client as PassportClient;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\PersonalAccessClient;
use Laravel\Passport\RefreshToken;
use Laravel\Passport\Token;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
@@ -150,10 +156,35 @@ class DatabaseSeeder extends Seeder
User::factory()->withPersonalOrganization()->create([ User::factory()->withPersonalOrganization()->create([
'email' => 'admin@example.com', 'email' => 'admin@example.com',
]); ]);
app(ClientRepository::class)->create(
null,
'desktop',
'solidtime://oauth/callback',
null,
false,
false,
false
);
} }
private function deleteAll(): void private function deleteAll(): void
{ {
// Laravel Passport tables
DB::table((new RefreshToken)->getTable())->delete();
DB::table((new Token)->getTable())->delete();
DB::table((new AuthCode)->getTable())->delete();
DB::table((new PersonalAccessClient)->getTable())->delete();
DB::table((new PassportClient)->getTable())->delete();
// Internal tables
DB::table('cache')->delete();
DB::table('cache_locks')->delete();
DB::table('jobs')->delete();
DB::table('failed_jobs')->delete();
DB::table('sessions')->delete();
// Application tables
DB::table((new Audit)->getTable())->delete(); DB::table((new Audit)->getTable())->delete();
DB::table((new TimeEntry)->getTable())->delete(); DB::table((new TimeEntry)->getTable())->delete();
DB::table((new Task)->getTable())->delete(); DB::table((new Task)->getTable())->delete();
@@ -161,8 +192,9 @@ class DatabaseSeeder extends Seeder
DB::table((new ProjectMember)->getTable())->delete(); DB::table((new ProjectMember)->getTable())->delete();
DB::table((new Project)->getTable())->delete(); DB::table((new Project)->getTable())->delete();
DB::table((new Client)->getTable())->delete(); DB::table((new Client)->getTable())->delete();
DB::table((new User)->getTable())->delete(); DB::table((new Member)->getTable())->delete();
DB::table((new OrganizationInvitation)->getTable())->delete(); DB::table((new OrganizationInvitation)->getTable())->delete();
DB::table((new Organization)->getTable())->delete(); DB::table((new Organization)->getTable())->delete();
DB::table((new User)->getTable())->delete();
} }
} }

View File

@@ -189,6 +189,13 @@ services:
entrypoint: /etc/minio/create_bucket.sh entrypoint: /etc/minio/create_bucket.sh
extra_hosts: extra_hosts:
- "storage.${NGINX_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}" - "storage.${NGINX_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}"
gotenberg:
image: gotenberg/gotenberg:8
networks:
- sail
healthcheck:
test: ["CMD", "curl", "--silent", "--fail", "http://localhost:3000/health"]
networks: networks:
reverse-proxy: reverse-proxy:
name: "${NETWORK_NAME}" name: "${NETWORK_NAME}"

View File

@@ -24,6 +24,7 @@ test('test that updating project member billable rate works for existing time en
await page.getByRole('button', { name: 'Add Member' }).click(); await page.getByRole('button', { name: 'Add Member' }).click();
await expect(page.getByText('Add Project Member').first()).toBeVisible(); await expect(page.getByText('Add Project Member').first()).toBeVisible();
await page.getByRole('button', { name: 'Select a member' }).click();
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Add Project Member' }).click(); await page.getByRole('button', { name: 'Add Project Member' }).click();

View File

@@ -191,14 +191,7 @@ test('test that updating a the start of an existing time entry in the overview w
'time_entry_range_selector' 'time_entry_range_selector'
); );
await timeEntryRangeElement.click(); await timeEntryRangeElement.click();
await page await page.getByTestId('time_picker_input').first().fill('1');
.getByTestId('time_entry_range_start')
.getByTestId('time_picker_hour')
.fill('1');
await page
.getByTestId('time_entry_range_start')
.getByTestId('time_picker_minute')
.fill('1');
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
return ( return (
@@ -213,7 +206,7 @@ test('test that updating a the start of an existing time entry in the overview w
}), }),
page page
.getByTestId('time_entry_range_end') .getByTestId('time_entry_range_end')
.getByTestId('time_picker_minute') .getByTestId('time_picker_input')
.press('Enter'), .press('Enter'),
]); ]);
}); });

View File

@@ -6,10 +6,12 @@ use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembe
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException; use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException; use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
@@ -33,6 +35,8 @@ return [
ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed', ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed',
ExportException::KEY => 'Export failed, please try again later or contact support', ExportException::KEY => 'Export failed, please try again later or contact support',
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members', OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
], ],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.', 'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
]; ];

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -57,7 +57,7 @@
} }
/* Track */ /* Track */
::-webkit-scrollbar-track { ::-webkit-scrollbar-track, ::-webkit-scrollbar-corner {
background: transparent; background: transparent;
} }

View File

@@ -1,13 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
import { useMembersStore } from '@/utils/useMembers'; import { useMembersStore } from '@/utils/useMembers';
import { UserIcon, XMarkIcon } from '@heroicons/vue/24/solid'; import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { useFocus } from '@vueuse/core'; import { useFocus } from '@vueuse/core';
import type { ProjectMember } from '@/packages/api/src'; import type { ProjectMember } from '@/packages/api/src';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; import { Badge, SelectDropdown } from '@/packages/ui/src';
import type { Member } from '@/packages/api/src';
const membersStore = useMembersStore(); const membersStore = useMembersStore();
const { members } = storeToRefs(membersStore); const { members } = storeToRefs(membersStore);
@@ -31,13 +30,9 @@ const searchInput = ref<HTMLInputElement | null>(null);
const searchValue = ref(''); const searchValue = ref('');
function isMemberSelected(id: string) {
return model.value === id;
}
useFocus(searchInput, { initialValue: true }); useFocus(searchInput, { initialValue: true });
const filteredMembers = computed(() => { const filteredMembers = computed<Member[]>(() => {
return members.value.filter((member) => { return members.value.filter((member) => {
return ( return (
member.name member.name
@@ -65,70 +60,7 @@ function resetHighlightedItem() {
} }
} }
function updateSearchValue(event: Event) {
const newInput = (event.target as HTMLInputElement).value;
if (newInput === ' ') {
searchValue.value = '';
const highlightedClientId = highlightedItemId.value;
if (highlightedClientId) {
const highlightedClient = members.value.find(
(member) => member.id === highlightedClientId
);
if (highlightedClient) {
model.value = highlightedClient.id;
}
}
} else {
searchValue.value = newInput;
}
}
const emit = defineEmits(['update:modelValue', 'changed']);
function updateMember(newValue: string | null) {
if (newValue) {
model.value = newValue;
nextTick(() => {
emit('changed');
});
}
}
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredMembers.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === 0) {
highlightedItemId.value =
filteredMembers.value[filteredMembers.value.length - 1].id;
} else {
highlightedItemId.value =
filteredMembers.value[currentHightlightedIndex - 1].id;
}
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredMembers.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === filteredMembers.value.length - 1) {
highlightedItemId.value = filteredMembers.value[0].id;
} else {
highlightedItemId.value =
filteredMembers.value[currentHightlightedIndex + 1].id;
}
}
}
const highlightedItemId = ref<string | null>(null); const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return members.value.find(
(member) => member.id === highlightedItemId.value
);
});
const currentValue = computed(() => { const currentValue = computed(() => {
if (model.value) { if (model.value) {
@@ -136,70 +68,27 @@ const currentValue = computed(() => {
} }
return searchValue.value; return searchValue.value;
}); });
const hasMemberSelected = computed(() => {
return model.value !== '';
});
const showMembersDropdown = ref(true);
</script> </script>
<template> <template>
<Dropdown <SelectDropdown
align="bottom-start" v-model="model"
width="300" :items="filteredMembers"
v-model="showMembersDropdown" :get-key-from-item="(member) => member.id"
:closeOnContentClick="true"> :get-name-for-item="(member) => member.name">
<template #trigger> <template v-slot:trigger>
<div class="flex relative"> <Badge
<div tag="button"
ref="reference" class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
class="absolute h-full items-center px-3 w-full flex justify-between"> <UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon> <div v-if="currentValue" class="flex-1 truncate">
<button {{ currentValue }}
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> </div>
<TextInput <div class="flex-1" v-else>Select a member...</div>
:value="currentValue" <ChevronDownIcon class="w-4 text-muted"></ChevronDownIcon>
:disabled="disabled" </Badge>
@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>
<template #content> </SelectDropdown>
<div
class="py-2 text-white px-3"
v-if="filteredMembers.length === 0">
All members are already added.
</div>
<div
v-for="member in filteredMembers"
:key="member.id"
role="option"
:value="member.id"
:class="{
'bg-card-background-active':
member.id === highlightedItemId,
}"
@click="updateMember(member.id)"
data-testid="client_dropdown_entries"
:data-client-id="member.id">
<ClientDropdownItem
:selected="isMemberSelected(member.id)"
:name="member.name"></ClientDropdownItem>
</div>
</template>
</Dropdown>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -151,6 +151,7 @@ const roleDescription = computed(() => {
v-if="billableRateSelect === 'custom-rate'"> v-if="billableRateSelect === 'custom-rate'">
<InputLabel <InputLabel
for="memberBillableRate" for="memberBillableRate"
class="mb-2"
value="Billable Rate" /> value="Billable Rate" />
<BillableRateInput <BillableRateInput
focus focus

View File

@@ -39,6 +39,7 @@ const { clients } = storeToRefs(useClientsStore());
const gridTemplate = computed(() => { const gridTemplate = computed(() => {
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`; return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
}); });
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
</script> </script>
<template> <template>
@@ -47,6 +48,7 @@ const gridTemplate = computed(() => {
:createClient :createClient
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:clients="clients" :clients="clients"
:enableEstimatedTime="isAllowedToPerformPremiumAction"
v-model:show="showCreateProjectModal"></ProjectCreateModal> v-model:show="showCreateProjectModal"></ProjectCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto"> <div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle"> <div class="inline-block min-w-full align-middle">
@@ -63,9 +65,19 @@ const gridTemplate = computed(() => {
v-if="projects.length === 0"> v-if="projects.length === 0">
<FolderPlusIcon <FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon> class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-white font-semibold">No projects found</h3> <h3 class="text-white font-semibold">
<p class="pb-5" v-if="canCreateProjects()"> {{
Create your first project now! canCreateProjects()
? 'No projects found'
: 'You are not a member of any projects'
}}
</h3>
<p class="pb-5 max-w-md mx-auto text-sm pt-1">
{{
canCreateProjects()
? 'Create your first project now!'
: 'Ask your manager to add you to a project as a team member.'
}}
</p> </p>
<SecondaryButton <SecondaryButton
v-if="canCreateProjects()" v-if="canCreateProjects()"

View File

@@ -89,6 +89,7 @@ useFocus(projectNameInput, { initialValue: true });
<div class="col-span-3 sm:col-span-1 flex-1"> <div class="col-span-3 sm:col-span-1 flex-1">
<InputLabel <InputLabel
for="billable_rate" for="billable_rate"
class="mb-2"
value="Billable Rate"></InputLabel> value="Billable Rate"></InputLabel>
<BillableRateInput <BillableRateInput
@keydown.enter="submit" @keydown.enter="submit"

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { SecondaryButton } from '@/packages/ui/src';
import { ArrowDownTrayIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import type { ExportFormat } from '@/types/reporting';
import { ref } from 'vue';
const props = defineProps<{
download: (format: ExportFormat) => Promise<void>;
}>();
const loading = ref(false);
function triggerDownload(format: ExportFormat) {
loading.value = true;
props.download(format).finally(() => {
loading.value = false;
});
}
</script>
<template>
<Dropdown align="bottom-end">
<template #trigger>
<SecondaryButton :icon="ArrowDownTrayIcon" :loading>
Export
</SecondaryButton>
</template>
<template #content>
<div class="flex flex-col space-y-1 p-1.5">
<SecondaryButton
v-if="false"
class="border-0 px-2"
@click="triggerDownload('pdf')"
>Export as PDF</SecondaryButton
>
<SecondaryButton
class="border-0 px-2"
@click="triggerDownload('xlsx')"
>Export as Excel</SecondaryButton
>
<SecondaryButton
class="border-0 px-2"
@click="triggerDownload('csv')"
>Export as CSV</SecondaryButton
>
<SecondaryButton
class="border-0 px-2"
@click="triggerDownload('ods')"
>Export as ODS
</SecondaryButton>
</div>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -1,187 +0,0 @@
<script setup lang="ts">
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 { nextTick, ref, watch } from 'vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TimeTrackerTagDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerTagDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import { getCurrentUserId } from '@/utils/useUser';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects';
import { useTagsStore } from '@/utils/useTags';
import type {
CreateClientBody,
CreateProjectBody,
Project,
Client,
} from '@/packages/api/src';
import { useClientsStore } from '@/utils/useClients';
import TimePicker from '@/packages/ui/src/Input/TimePicker.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
const clientStore = useClientsStore();
const { clients } = storeToRefs(clientStore);
const { createTimeEntry } = useTimeEntriesStore();
const show = defineModel('show', { default: false });
const saving = ref(false);
async function createProject(
project: CreateProjectBody
): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
async function createClient(
body: CreateClientBody
): Promise<Client | undefined> {
return await useClientsStore().createClient(body);
}
const description = ref<HTMLInputElement | null>(null);
watch(show, (value) => {
if (value) {
nextTick(() => {
description.value?.focus();
});
}
});
const timeEntryDefaultValues = {
description: '',
project_id: null,
task_id: null,
tags: [],
billable: false,
start: getDayJsInstance().utc().format(),
end: getDayJsInstance().utc().format(),
user_id: getCurrentUserId(),
};
const timeEntry = ref({ ...timeEntryDefaultValues });
const localStart = ref(
getLocalizedDayJs(timeEntryDefaultValues.start).format()
);
const localEnd = ref(getLocalizedDayJs(timeEntryDefaultValues.end).format());
watch(localStart, (value) => {
timeEntry.value.start = getLocalizedDayJs(value).utc().format();
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
localEnd.value = value;
}
});
watch(localEnd, (value) => {
timeEntry.value.end = getLocalizedDayJs(value).utc().format();
});
async function submit() {
await createTimeEntry(timeEntry.value);
timeEntry.value = { ...timeEntryDefaultValues };
localStart.value = getLocalizedDayJs(timeEntryDefaultValues.start).format();
localEnd.value = getLocalizedDayJs(timeEntryDefaultValues.end).format();
show.value = false;
}
const { tags } = storeToRefs(useTagsStore());
async function createTag(tag: string) {
return await useTagsStore().createTag(tag);
}
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create manual time entry </span>
</div>
</template>
<template #content>
<div class="sm:flex items-end space-y-2 sm:space-y-0 sm:space-x-4">
<div class="flex-1">
<InputLabel for="description" value="Description" />
<TextInput
id="description"
ref="description"
v-model="timeEntry.description"
@keydown.enter="submit"
type="text"
class="mt-1 block w-full" />
</div>
<div class="flex items-center justify-between">
<div>
<TimeTrackerProjectTaskDropdown
:clients
:createProject
:createClient
:currency="getOrganizationCurrencyString()"
class="mt-1"
size="xlarge"
:projects="projects"
:tasks="tasks"
v-model:project="timeEntry.project_id"
v-model:task="
timeEntry.task_id
"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center space-x-2 px-4">
<TimeTrackerTagDropdown
:tags="tags"
:createTag="createTag"
v-model="timeEntry.tags"></TimeTrackerTagDropdown>
<BillableToggleButton
v-model="timeEntry.billable"></BillableToggleButton>
</div>
</div>
</div>
<div class="flex pt-4">
<div class="flex-1">
<InputLabel>Start</InputLabel>
<div class="flex items-center space-x-4 mt-1">
<DatePicker v-model="localStart"></DatePicker>
<TimePicker
size="large"
v-model="localStart"></TimePicker>
</div>
</div>
<div class="flex-1">
<InputLabel>End</InputLabel>
<div class="flex items-center space-x-4 mt-1">
<DatePicker v-model="localEnd"></DatePicker>
<TimePicker
size="large"
v-model="localEnd"></TimePicker>
</div>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Create Time Entry
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -24,6 +24,8 @@ import type {
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue'; import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
const page = usePage<{ const page = usePage<{
auth: { auth: {
@@ -82,7 +84,11 @@ const isRunningInDifferentOrganization = computed(() => {
async function createProject( async function createProject(
project: CreateProjectBody project: CreateProjectBody
): Promise<Project | undefined> { ): Promise<Project | undefined> {
return await useProjectsStore().createProject(project); const newProject = await useProjectsStore().createProject(project);
if (newProject) {
currentTimeEntry.value.project_id = newProject.id;
}
return newProject;
} }
async function createClient(client: CreateClientBody) { async function createClient(client: CreateClientBody) {
return await useClientsStore().createClient(client); return await useClientsStore().createClient(client);
@@ -111,6 +117,8 @@ const { tags } = storeToRefs(useTagsStore());
<TimeTrackerControls <TimeTrackerControls
:createProject :createProject
:enableEstimatedTime="isAllowedToPerformPremiumAction()"
:canCreateProject="canCreateProjects()"
:createClient :createClient
:clients :clients
:tags :tags

View File

@@ -264,7 +264,7 @@ const disableTwoFactorAuthentication = () => {
@confirmed="disableTwoFactorAuthentication"> @confirmed="disableTwoFactorAuthentication">
<SecondaryButton <SecondaryButton
v-if="confirming" v-if="confirming"
:class="{ 'opacity-25': disabling }" :class="disabling ? 'opacity-25' : ''"
:disabled="disabling"> :disabled="disabling">
Cancel Cancel
</SecondaryButton> </SecondaryButton>

View File

@@ -22,6 +22,7 @@ import type {
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { getCurrentRole } from '@/utils/useUser'; import { getCurrentRole } from '@/utils/useUser';
import { useOrganizationStore } from '@/utils/useOrganization'; import { useOrganizationStore } from '@/utils/useOrganization';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
onMounted(() => { onMounted(() => {
useProjectsStore().fetchProjects(); useProjectsStore().fetchProjects();
@@ -94,6 +95,7 @@ const showBillableRate = computed(() => {
</SecondaryButton> </SecondaryButton>
<ProjectCreateModal <ProjectCreateModal
:createProject :createProject
:enableEstimatedTime="isAllowedToPerformPremiumAction"
:createClient :createClient
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:clients="clients" :clients="clients"

View File

@@ -12,6 +12,7 @@ import {
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue'; import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue'; import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { import {
formatHumanReadableDuration, formatHumanReadableDuration,
@@ -21,7 +22,7 @@ import {
import { type GroupingOption, useReportingStore } from '@/utils/useReporting'; import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import type { AggregatedTimeEntriesQueryParams } from '@/packages/api/src'; import { type AggregatedTimeEntriesQueryParams, api } from '@/packages/api/src';
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue'; import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue'; import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue'; import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
@@ -31,7 +32,11 @@ import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroup
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue'; import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { getCurrentMembershipId, getCurrentRole } from '@/utils/useUser'; import {
getCurrentMembershipId,
getCurrentOrganizationId,
getCurrentRole,
} from '@/utils/useUser';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue'; import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import { useTagsStore } from '@/utils/useTags'; import { useTagsStore } from '@/utils/useTags';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
@@ -39,6 +44,10 @@ import { useSessionStorage, useStorage } from '@vueuse/core';
import TabBar from '@/Components/Common/TabBar/TabBar.vue'; import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue'; import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import { router } from '@inertiajs/vue3'; import { router } from '@inertiajs/vue3';
import { useNotificationsStore } from '@/utils/notification';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import type { ExportFormat } from '@/types/reporting';
const { handleApiRequestNotifications } = useNotificationsStore();
const startDate = useSessionStorage<string>( const startDate = useSessionStorage<string>(
'reporting-start-date', 'reporting-start-date',
@@ -66,7 +75,7 @@ const { aggregatedGraphTimeEntries, aggregatedTableTimeEntries } =
const { groupByOptions } = reportingStore; const { groupByOptions } = reportingStore;
function getFilterAttributes() { function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
let params: AggregatedTimeEntriesQueryParams = { let params: AggregatedTimeEntriesQueryParams = {
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(), start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
end: getLocalizedDayJs(endDate.value).endOf('day').utc().format(), end: getLocalizedDayJs(endDate.value).endOf('day').utc().format(),
@@ -94,16 +103,12 @@ function getFilterAttributes() {
} }
function updateGraphReporting() { function updateGraphReporting() {
const diffInDays = getDayJsInstance()(endDate.value).diff(
getDayJsInstance()(startDate.value),
'd'
);
const params = getFilterAttributes(); const params = getFilterAttributes();
if (getCurrentRole() === 'employee') { if (getCurrentRole() === 'employee') {
params.member_id = getCurrentMembershipId(); params.member_id = getCurrentMembershipId();
} }
params.fill_gaps_in_time_groups = 'true'; params.fill_gaps_in_time_groups = 'true';
params.group = getOptimalGroupingOption(diffInDays); params.group = getOptimalGroupingOption(startDate.value, endDate.value);
useReportingStore().fetchGraphReporting(params); useReportingStore().fetchGraphReporting(params);
} }
@@ -130,10 +135,18 @@ function updateReporting() {
updateTableReporting(); updateTableReporting();
} }
function getOptimalGroupingOption(diff: number): 'day' | 'week' | 'month' { function getOptimalGroupingOption(
if (diff <= 31) { startDate: string,
endDate: string
): 'day' | 'week' | 'month' {
const diffInDays = getDayJsInstance()(endDate).diff(
getDayJsInstance()(startDate),
'd'
);
if (diffInDays <= 31) {
return 'day'; return 'day';
} else if (diff <= 200) { } else if (diffInDays <= 200) {
return 'week'; return 'week';
} else { } else {
return 'month'; return 'month';
@@ -149,6 +162,33 @@ const { tags } = storeToRefs(useTagsStore());
async function createTag(tag: string) { async function createTag(tag: string) {
return await useTagsStore().createTag(tag); return await useTagsStore().createTag(tag);
} }
async function downloadExport(format: ExportFormat) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const response = await handleApiRequestNotifications(
() =>
api.exportAggregatedTimeEntries({
params: {
organization: organizationId,
},
queries: {
...getFilterAttributes(),
group: group.value,
sub_group: subGroup.value,
history_group: getOptimalGroupingOption(
startDate.value,
endDate.value
),
format: format,
},
}),
'Export successful',
'Export failed'
);
window.open(response.download_url, '_self')?.focus();
}
}
</script> </script>
<template> <template>
@@ -170,8 +210,10 @@ async function createTag(tag: string) {
> >
</TabBar> </TabBar>
</div> </div>
<ReportingExportButton
:download="downloadExport"></ReportingExportButton>
</MainContainer> </MainContainer>
<div class="p-3 w-full border-b border-default-background-separator"> <div class="py-2.5 w-full border-b border-default-background-separator">
<MainContainer <MainContainer
class="sm:flex space-y-4 sm:space-y-0 justify-between"> class="sm:flex space-y-4 sm:space-y-0 justify-between">
<div <div

View File

@@ -64,7 +64,12 @@ import {
import { useQuery, useQueryClient } from '@tanstack/vue-query'; import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { useTimeEntriesStore } from '@/utils/useTimeEntries'; import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import TimeEntryMassActionRow from '@/Components/Common/TimeEntry/TimeEntryMassActionRow.vue'; import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import type { ExportFormat } from '@/types/reporting';
import { useNotificationsStore } from '@/utils/notification';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
const startDate = useSessionStorage<string>( const startDate = useSessionStorage<string>(
'reporting-start-date', 'reporting-start-date',
@@ -118,7 +123,9 @@ function getFilterAttributes() {
const currentTimeEntryStore = useCurrentTimeEntryStore(); const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore); const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
const { setActiveState, startLiveTimer } = currentTimeEntryStore; const { setActiveState, startLiveTimer } = currentTimeEntryStore;
const { createTimeEntry, updateTimeEntry } = useTimeEntriesStore(); const { handleApiRequestNotifications } = useNotificationsStore();
const { createTimeEntry, updateTimeEntry, updateTimeEntries } =
useTimeEntriesStore();
const { tags } = storeToRefs(useTagsStore()); const { tags } = storeToRefs(useTagsStore());
@@ -212,6 +219,26 @@ async function clearSelectionAndState() {
selectedTimeEntries.value = []; selectedTimeEntries.value = [];
await updateFilteredTimeEntries(); await updateFilteredTimeEntries();
} }
async function downloadExport(format: ExportFormat) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const response = await handleApiRequestNotifications(
() =>
api.exportTimeEntries({
params: {
organization: organizationId,
},
queries: {
...getFilterAttributes(),
format: format,
},
}),
'Export successful',
'Export failed'
);
window.open(response.download_url, '_self')?.focus();
}
}
</script> </script>
<template> <template>
@@ -234,6 +261,8 @@ async function clearSelectionAndState() {
</TabBarItem> </TabBarItem>
</TabBar> </TabBar>
</div> </div>
<ReportingExportButton
:download="downloadExport"></ReportingExportButton>
</MainContainer> </MainContainer>
<div class="py-2.5 w-full border-b border-default-background-separator"> <div class="py-2.5 w-full border-b border-default-background-separator">
<MainContainer <MainContainer
@@ -338,18 +367,34 @@ async function clearSelectionAndState() {
</div> </div>
<TimeEntryMassActionRow <TimeEntryMassActionRow
:selected-time-entries="selectedTimeEntries" :selected-time-entries="selectedTimeEntries"
:canCreateProject="canCreateProjects()"
:enableEstimatedTime="isAllowedToPerformPremiumAction()"
@submit="clearSelectionAndState" @submit="clearSelectionAndState"
:delete-selected="deleteSelected" :delete-selected="deleteSelected"
@select-all="selectedTimeEntries = [...timeEntries]" @select-all="selectedTimeEntries = [...timeEntries]"
@unselect-all="selectedTimeEntries = []" @unselect-all="selectedTimeEntries = []"
:all-selected=" :all-selected="selectedTimeEntries.length === timeEntries.length"
selectedTimeEntries.length === timeEntries.length :projects="projects"
"></TimeEntryMassActionRow> :tasks="tasks"
:tags="tags"
:currency="getOrganizationCurrencyString()"
:clients="clients"
:update-time-entries="
(args) =>
updateTimeEntries(
selectedTimeEntries.map((timeEntry) => timeEntry.id),
args
)
"
:create-project="createProject"
:create-client="createClient"
:createTag="createTag"></TimeEntryMassActionRow>
<div class="w-full relative"> <div class="w-full relative">
<div v-for="entry in timeEntries" :key="entry.id"> <div v-for="entry in timeEntries" :key="entry.id">
<TimeEntryRow <TimeEntryRow
:selected="selectedTimeEntries.includes(entry)" :selected="selectedTimeEntries.includes(entry)"
@selected="selectedTimeEntries.push(entry)" @selected="selectedTimeEntries.push(entry)"
:canCreateProject="canCreateProjects()"
@unselected=" @unselected="
selectedTimeEntries = selectedTimeEntries.filter( selectedTimeEntries = selectedTimeEntries.filter(
(item) => item.id !== entry.id (item) => item.id !== entry.id
@@ -357,6 +402,7 @@ async function clearSelectionAndState() {
" "
:createClient :createClient
:createProject :createProject
:enableEstimatedTime="isAllowedToPerformPremiumAction()"
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
:tags="tags" :tags="tags"

View File

@@ -71,6 +71,7 @@ function checkForConfirmationModal() {
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<InputLabel <InputLabel
for="organizationBillableRate" for="organizationBillableRate"
class="mb-2"
value="Organization Billable Rate" /> value="Organization Billable Rate" />
<BillableRateInput <BillableRateInput
v-if="organization" v-if="organization"

View File

@@ -24,16 +24,22 @@ import { useProjectsStore } from '@/utils/useProjects';
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue'; import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
import { useTagsStore } from '@/utils/useTags'; import { useTagsStore } from '@/utils/useTags';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import TimeEntryCreateModal from '@/Components/Common/TimeEntry/TimeEntryCreateModal.vue'; import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import TimeEntryMassActionRow from '@/Components/Common/TimeEntry/TimeEntryMassActionRow.vue'; import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
const timeEntriesStore = useTimeEntriesStore(); const timeEntriesStore = useTimeEntriesStore();
const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore); const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore);
const { updateTimeEntry, fetchTimeEntries, createTimeEntry } = const { updateTimeEntry, fetchTimeEntries, createTimeEntry } =
useTimeEntriesStore(); useTimeEntriesStore();
async function updateTimeEntries(ids: string[], changes: Partial<TimeEntry>) { async function updateTimeEntries(
ids: string[],
changes: UpdateMultipleTimeEntriesChangeset
) {
await useTimeEntriesStore().updateTimeEntries(ids, changes); await useTimeEntriesStore().updateTimeEntries(ids, changes);
fetchTimeEntries(); fetchTimeEntries();
} }
@@ -114,6 +120,17 @@ function deleteSelected() {
<template> <template>
<TimeEntryCreateModal <TimeEntryCreateModal
:enableEstimatedTime="isAllowedToPerformPremiumAction()"
:createProject="createProject"
:createClient="createClient"
:createTag="createTag"
:createTimeEntry="createTimeEntry"
:currency="getOrganizationCurrencyString()"
:canCreateProject="canCreateProjects()"
:projects
:tasks
:tags
:clients
v-model:show="showManualTimeEntryModal"></TimeEntryCreateModal> v-model:show="showManualTimeEntryModal"></TimeEntryCreateModal>
<AppLayout title="Dashboard" data-testid="time_view"> <AppLayout title="Dashboard" data-testid="time_view">
<MainContainer <MainContainer
@@ -135,14 +152,33 @@ function deleteSelected() {
</MainContainer> </MainContainer>
<TimeEntryMassActionRow <TimeEntryMassActionRow
:selected-time-entries="selectedTimeEntries" :selected-time-entries="selectedTimeEntries"
:enableEstimatedTime="isAllowedToPerformPremiumAction()"
:canCreateProject="canCreateProjects()"
@submit="clearSelectionAndState" @submit="clearSelectionAndState"
:all-selected="selectedTimeEntries.length === timeEntries.length" :all-selected="selectedTimeEntries.length === timeEntries.length"
@select-all="selectedTimeEntries = [...timeEntries]" @select-all="selectedTimeEntries = [...timeEntries]"
@unselect-all="selectedTimeEntries = []" @unselect-all="selectedTimeEntries = []"
:delete-selected="deleteSelected"></TimeEntryMassActionRow> :delete-selected="deleteSelected"
:projects="projects"
:tasks="tasks"
:tags="tags"
:currency="getOrganizationCurrencyString()"
:clients="clients"
:update-time-entries="
(args) =>
updateTimeEntries(
selectedTimeEntries.map((timeEntry) => timeEntry.id),
args
)
"
:create-project="createProject"
:create-client="createClient"
:createTag="createTag"></TimeEntryMassActionRow>
<TimeEntryGroupedTable <TimeEntryGroupedTable
v-model:selected="selectedTimeEntries" v-model:selected="selectedTimeEntries"
:createProject :createProject
:enableEstimatedTime="isAllowedToPerformPremiumAction()"
:canCreateProject="canCreateProjects()"
:clients :clients
:createClient :createClient
:updateTimeEntry :updateTimeEntry

View File

@@ -1,6 +1,6 @@
{ {
"name": "@solidtime/api", "name": "@solidtime/api",
"version": "0.0.3", "version": "0.0.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@solidtime/api", "name": "@solidtime/api",
"version": "0.0.3", "version": "0.0.4",
"description": "Package containing the solidtime api client and type declarations", "description": "Package containing the solidtime api client and type declarations",
"main": "./dist/solidtime-api.umd.cjs", "main": "./dist/solidtime-api.umd.cjs",
"module": "./dist/solidtime-api.js", "module": "./dist/solidtime-api.js",

View File

@@ -123,7 +123,7 @@ export type TimeEntriesQueryParams = ZodiosQueryParamsByAlias<
export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias< export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias<
SolidTimeApi, SolidTimeApi,
'getAggregatedTimeEntries' 'getAggregatedTimeEntries'
>; > & { start: string; end: string };
export type OrganizationResponse = ZodiosResponseByAlias< export type OrganizationResponse = ZodiosResponseByAlias<
SolidTimeApi, SolidTimeApi,

View File

@@ -105,7 +105,7 @@ const ProjectMemberResource = z
.passthrough(); .passthrough();
const ProjectMemberStoreRequest = z const ProjectMemberStoreRequest = z
.object({ .object({
member_id: z.string().uuid(), member_id: z.string(),
billable_rate: z.union([z.number(), z.null()]).optional(), billable_rate: z.union([z.number(), z.null()]).optional(),
}) })
.passthrough(); .passthrough();
@@ -172,14 +172,14 @@ const TimeEntryResource = z
.passthrough(); .passthrough();
const TimeEntryStoreRequest = z const TimeEntryStoreRequest = z
.object({ .object({
member_id: z.string().uuid(), member_id: z.string(),
project_id: z.union([z.string(), z.null()]).optional(), project_id: z.union([z.string(), z.null()]).optional(),
task_id: z.union([z.string(), z.null()]).optional(), task_id: z.union([z.string(), z.null()]).optional(),
start: z.string(), start: z.string(),
end: z.union([z.string(), z.null()]).optional(), end: z.union([z.string(), z.null()]).optional(),
billable: z.boolean(), billable: z.boolean(),
description: z.union([z.string(), z.null()]).optional(), description: z.union([z.string(), z.null()]).optional(),
tags: z.union([z.array(z.string().uuid()), z.null()]).optional(), tags: z.union([z.array(z.string()), z.null()]).optional(),
}) })
.passthrough(); .passthrough();
const TimeEntryUpdateMultipleRequest = z const TimeEntryUpdateMultipleRequest = z
@@ -187,12 +187,12 @@ const TimeEntryUpdateMultipleRequest = z
ids: z.array(z.string().uuid()), ids: z.array(z.string().uuid()),
changes: z changes: z
.object({ .object({
member_id: z.string().uuid(), member_id: z.string(),
project_id: z.union([z.string(), z.null()]), project_id: z.union([z.string(), z.null()]),
task_id: z.union([z.string(), z.null()]), task_id: z.union([z.string(), z.null()]),
billable: z.boolean(), billable: z.boolean(),
description: z.union([z.string(), z.null()]), description: z.union([z.string(), z.null()]),
tags: z.union([z.array(z.string().uuid()), z.null()]), tags: z.union([z.array(z.string()), z.null()]),
}) })
.partial() .partial()
.passthrough(), .passthrough(),
@@ -200,14 +200,14 @@ const TimeEntryUpdateMultipleRequest = z
.passthrough(); .passthrough();
const TimeEntryUpdateRequest = z const TimeEntryUpdateRequest = z
.object({ .object({
member_id: z.string().uuid(), member_id: z.string(),
project_id: z.union([z.string(), z.null()]), project_id: z.union([z.string(), z.null()]),
task_id: z.union([z.string(), z.null()]), task_id: z.union([z.string(), z.null()]),
start: z.string(), start: z.string(),
end: z.union([z.string(), z.null()]), end: z.union([z.string(), z.null()]),
billable: z.boolean(), billable: z.boolean(),
description: z.union([z.string(), z.null()]), description: z.union([z.string(), z.null()]),
tags: z.union([z.array(z.string().uuid()), z.null()]), tags: z.union([z.array(z.string()), z.null()]),
}) })
.partial() .partial()
.passthrough(); .passthrough();
@@ -370,7 +370,7 @@ const endpoints = makeApi([
{ {
name: 'page', name: 'page',
type: 'Query', type: 'Query',
schema: z.number().int().gte(1).optional(), schema: z.number().int().gte(1).lte(2147483647).optional(),
}, },
{ {
name: 'archived', name: 'archived',
@@ -1317,7 +1317,7 @@ const endpoints = makeApi([
{ {
name: 'page', name: 'page',
type: 'Query', type: 'Query',
schema: z.number().int().gte(1).optional(), schema: z.number().int().gte(1).lte(2147483647).optional(),
}, },
{ {
name: 'archived', name: 'archived',
@@ -1889,7 +1889,7 @@ const endpoints = makeApi([
{ {
name: 'project_id', name: 'project_id',
type: 'Query', type: 'Query',
schema: z.string().uuid().optional(), schema: z.string().optional(),
}, },
{ {
name: 'done', name: 'done',
@@ -2118,7 +2118,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
{ {
name: 'member_id', name: 'member_id',
type: 'Query', type: 'Query',
schema: z.string().uuid().optional(), schema: z.string().optional(),
}, },
{ {
name: 'start', name: 'start',
@@ -2148,7 +2148,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
{ {
name: 'offset', name: 'offset',
type: 'Query', type: 'Query',
schema: z.number().int().gte(0).optional(), schema: z.number().int().gte(0).lte(2147483647).optional(),
}, },
{ {
name: 'only_full_dates', name: 'only_full_dates',
@@ -2158,27 +2158,27 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
{ {
name: 'member_ids', name: 'member_ids',
type: 'Query', type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
{ {
name: 'client_ids', name: 'client_ids',
type: 'Query', type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
{ {
name: 'project_ids', name: 'project_ids',
type: 'Query', type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
{ {
name: 'tag_ids', name: 'tag_ids',
type: 'Query', type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
{ {
name: 'task_ids', name: 'task_ids',
type: 'Query', type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
{ {
name: 'user_id', name: 'user_id',
@@ -2524,12 +2524,12 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
{ {
name: 'member_id', name: 'member_id',
type: 'Query', type: 'Query',
schema: z.string().uuid().optional(), schema: z.string().optional(),
}, },
{ {
name: 'user_id', name: 'user_id',
type: 'Query', type: 'Query',
schema: z.string().uuid().optional(), schema: z.string().optional(),
}, },
{ {
name: 'start', name: 'start',
@@ -2559,27 +2559,27 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
{ {
name: 'member_ids', name: 'member_ids',
type: 'Query', type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
{ {
name: 'project_ids', name: 'project_ids',
type: 'Query', type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
{ {
name: 'client_ids', name: 'client_ids',
type: 'Query', type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
{ {
name: 'tag_ids', name: 'tag_ids',
type: 'Query', type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
{ {
name: 'task_ids', name: 'task_ids',
type: 'Query', type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
], ],
response: z response: z
@@ -2656,6 +2656,272 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
}, },
], ],
}, },
{
method: 'get',
path: '/v1/organizations/:organization/time-entries/aggregate/export',
alias: 'exportAggregatedTimeEntries',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'format',
type: 'Query',
schema: z.enum(['csv', 'pdf', 'xlsx', 'ods']),
},
{
name: 'group',
type: 'Query',
schema: z.enum([
'day',
'week',
'month',
'year',
'user',
'project',
'task',
'client',
'billable',
'description',
]),
},
{
name: 'sub_group',
type: 'Query',
schema: z.enum([
'day',
'week',
'month',
'year',
'user',
'project',
'task',
'client',
'billable',
'description',
]),
},
{
name: 'history_group',
type: 'Query',
schema: z.enum(['day', 'week', 'month', 'year']),
},
{
name: 'member_id',
type: 'Query',
schema: z.string().optional(),
},
{
name: 'user_id',
type: 'Query',
schema: z.string().optional(),
},
{
name: 'start',
type: 'Query',
schema: z.string(),
},
{
name: 'end',
type: 'Query',
schema: z.string(),
},
{
name: 'active',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'billable',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'fill_gaps_in_time_groups',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'member_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'project_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'client_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'task_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
],
response: z.object({ download_url: z.string() }).passthrough(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/time-entries/export',
alias: 'exportTimeEntries',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'format',
type: 'Query',
schema: z.enum(['csv', 'pdf', 'xlsx', 'ods']),
},
{
name: 'member_id',
type: 'Query',
schema: z.string().uuid().optional(),
},
{
name: 'start',
type: 'Query',
schema: start,
},
{
name: 'end',
type: 'Query',
schema: start,
},
{
name: 'active',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'billable',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'limit',
type: 'Query',
schema: z.number().int().gte(1).lte(500).optional(),
},
{
name: 'only_full_dates',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'member_ids',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
},
{
name: 'project_ids',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
},
{
name: 'tag_ids',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
},
{
name: 'task_ids',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
},
],
response: z.object({ download_url: z.string() }).passthrough(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{ {
method: 'get', method: 'get',
path: '/v1/users/me', path: '/v1/users/me',

View File

@@ -1,12 +1,12 @@
{ {
"name": "@solidtime/ui", "name": "@solidtime/ui",
"version": "0.0.8", "version": "0.0.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@solidtime/ui", "name": "@solidtime/ui",
"version": "0.0.8", "version": "0.0.11",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "devDependencies": {
"@types/node": "^22.4.1", "@types/node": "^22.4.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@solidtime/ui", "name": "@solidtime/ui",
"version": "0.0.8", "version": "0.0.11",
"description": "Package containing the solidtime ui components", "description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs", "main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js", "module": "./dist/solidtime-ui-lib.js",

View File

@@ -2,16 +2,20 @@
import type { HtmlButtonType } from '@/types/dom'; import type { HtmlButtonType } from '@/types/dom';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { type Component } from 'vue'; import { type Component } from 'vue';
import LoadingSpinner from '../LoadingSpinner.vue';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
type: HtmlButtonType; type: HtmlButtonType;
icon?: Component; icon?: Component;
size: 'small' | 'base'; size: 'small' | 'base';
loading: boolean;
class?: string;
}>(), }>(),
{ {
type: 'button', type: 'button',
size: 'base', size: 'base',
loading: false,
} }
); );
@@ -24,20 +28,23 @@ const sizeClasses = {
<template> <template>
<button <button
:type="type" :type="type"
:disabled="loading"
:class=" :class="
twMerge( twMerge(
'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-white rounded-lg font-semibold inline-flex items-center space-x-1.5 focus-visible:border-input-border-active focus:outline-none focus:ring-0 disabled:opacity-25 ease-in-out', 'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-white rounded-lg font-semibold inline-flex items-center space-x-1.5 focus-visible:border-input-border-active focus:outline-none focus:ring-0 disabled:opacity-25 ease-in-out',
sizeClasses[props.size] sizeClasses[props.size],
props.class
) )
"> ">
<span <span
:class=" :class="
twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '') twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')
"> ">
<LoadingSpinner v-if="loading"></LoadingSpinner>
<component <component
v-if="props.icon" v-if="props.icon && !loading"
:is="props.icon" :is="props.icon"
class="w-4 sm:w-5 h-4 sm:h-5 -ml-0.5 sm:-ml-1"></component> class="text-text-tertiary w-4 -ml-0.5 mr-1"></component>
<span> <span>
<slot /> <slot />
</span> </span>

View File

@@ -82,7 +82,7 @@ const inputValue = ref(formatValue(model.value));
type="text" type="text"
:name="name" :name="name"
placeholder="Billable Rate" placeholder="Billable Rate"
class="mt-2 block w-full" class="block w-full"
autocomplete="teamMemberRate" /> autocomplete="teamMemberRate" />
<div <div
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium pointer-events-none"> class="absolute top-0 right-0 h-full flex items-center px-4 font-medium pointer-events-none">

View File

@@ -54,7 +54,7 @@ const emit = defineEmits(['changed']);
@keydown.enter="updateDate" @keydown.enter="updateDate"
:class=" :class="
twMerge( twMerge(
'bg-input-background border text-white border-input-border rounded-md', 'bg-input-background border text-white border-input-border focus-visible:outline-0 focus-visible:border-input-border-active focus-visible:ring-0 rounded-md',
props.class props.class
) )
" "
@@ -68,6 +68,7 @@ const emit = defineEmits(['changed']);
<style scoped> <style scoped>
input::-webkit-calendar-picker-indicator { input::-webkit-calendar-picker-indicator {
filter: invert(1); filter: invert(1);
opacity: 0.2; opacity: 0.2;
} }
</style> </style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'; import { onMounted, onUnmounted, ref, watch } from 'vue';
import { import {
flip, flip,
limitShift, limitShift,
@@ -10,6 +10,8 @@ import {
} from '@floating-ui/vue'; } from '@floating-ui/vue';
import { offset } from '@floating-ui/vue'; import { offset } from '@floating-ui/vue';
import { autoUpdate } from '@floating-ui/vue'; import { autoUpdate } from '@floating-ui/vue';
import { useId } from 'radix-vue';
import { isLastLayer, layers } from '@/packages/ui/src/utils/dismissableLayer';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -24,17 +26,28 @@ const props = withDefaults(
const emit = defineEmits(['open', 'submit']); const emit = defineEmits(['open', 'submit']);
const open = defineModel({ default: false }); const open = defineModel({ default: false });
const id = useId();
const closeOnEscape = (e: KeyboardEvent) => { const closeOnEscape = (e: KeyboardEvent) => {
if (open.value && e.key === 'Escape') { if (isLastLayer(id)) {
open.value = false; if (open.value && e.key === 'Escape') {
} open.value = false;
if (open.value && e.key === 'Enter') { }
emit('submit'); if (open.value && e.key === 'Enter') {
if (props.closeOnContentClick) open.value = false; emit('submit');
if (props.closeOnContentClick) open.value = false;
}
} }
}; };
watch(open, (value) => {
if (value) {
layers.value.push(id);
} else {
layers.value = layers.value.filter((layer) => layer !== id);
}
});
onMounted(() => document.addEventListener('keydown', closeOnEscape)); onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape)); onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import parse from 'parse-duration';
import { onMounted, ref, watch } from 'vue';
import {
formatHumanReadableDuration,
getDayJsInstance,
} from '@/packages/ui/src/utils/time';
import dayjs from 'dayjs';
import { twMerge } from 'tailwind-merge';
import { TextInput } from '@/packages/ui/src';
const temporaryCustomTimerEntry = ref<string>('');
const start = defineModel('start', {
default: '',
});
const end = defineModel('end', {
default: '',
});
function isHHMM(value: string): boolean {
return HHMMtimeRegex.test(value);
}
function parseHHMM(value: string): string[] | null {
return value.match(HHMMtimeRegex);
}
function updateDuration() {
const time = parse(temporaryCustomTimerEntry.value, 's');
if (isNumeric(temporaryCustomTimerEntry.value)) {
const newStartDate = getDayJsInstance()(end.value).subtract(
parseInt(temporaryCustomTimerEntry.value),
'm'
);
start.value = newStartDate.utc().format();
} else if (isHHMM(temporaryCustomTimerEntry.value)) {
const results = parseHHMM(temporaryCustomTimerEntry.value);
if (results) {
const newStartDate = getDayJsInstance()(end.value)
.subtract(parseInt(results[1]), 'h')
.subtract(parseInt(results[2]), 'm');
start.value = newStartDate.utc().format();
}
}
// try to parse natural language like "1h 30m"
else if (time && time > 1) {
const newStartDate = getDayJsInstance()(end.value).subtract(time, 's');
start.value = newStartDate.utc().format();
}
// fallback to minutes if just a number is given
updateTimeEntryInputValue();
}
function isNumeric(value: string) {
return /^-?\d+$/.test(value);
}
const props = defineProps<{
class?: string;
}>();
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
watch([start, end], updateTimeEntryInputValue);
onMounted(() => updateTimeEntryInputValue());
function updateTimeEntryInputValue() {
if (start.value && end.value) {
const startTime = dayjs(start.value);
const diff = getDayJsInstance()(end.value).diff(startTime, 'seconds');
temporaryCustomTimerEntry.value = formatHumanReadableDuration(diff);
}
}
</script>
<template>
<TextInput
ref="inputField"
@blur="updateDuration"
@keydown.enter="updateDuration"
v-model="temporaryCustomTimerEntry"
:class="twMerge('text-text-secondary', props.class)"
type="text" />
</template>
<style scoped></style>

View File

@@ -1,28 +1,32 @@
<script setup lang="ts" generic="T"> <script setup lang="ts" generic="T">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { type Component, computed, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue'; import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
import { onKeyStroke } from '@vueuse/core'; import { onKeyStroke } from '@vueuse/core';
import { type Placement } from '@floating-ui/vue'; import { type Placement } from '@floating-ui/vue';
import { twMerge } from 'tailwind-merge';
const model = defineModel<string | null>({ const model = defineModel<string | null>({
default: null, default: null,
}); });
const open = defineModel('open', {
default: false,
});
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
items: T[]; items: T[];
getKeyFromItem: (item: T) => string | null; getKeyFromItem: (item: T) => string | null;
getNameForItem: (item: T) => string; getNameForItem: (item: T) => string;
align?: Placement; align?: Placement;
class?: string;
}>(), }>(),
{ {
align: 'bottom-start', align: 'bottom-start',
} }
); );
const open = ref(false); const dropdownViewport = ref<HTMLDivElement | null>(null);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref(''); const searchValue = ref('');
@@ -36,12 +40,36 @@ const filteredItems = computed<T[]>(() => {
}); });
}); });
const highlightedItemId = ref<string | null>(model.value);
watch(model, () => {
highlightedItemId.value = model.value;
});
watch(filteredItems, () => { watch(filteredItems, () => {
if (filteredItems.value.length > 0) { if (
filteredItems.value.length > 0 &&
filteredItems.value.find(
(item) => props.getKeyFromItem(item) === highlightedItemId.value
) === undefined
) {
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]); highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
} }
}); });
watch(highlightedItemId, () => {
if (highlightedItemId.value) {
const highlightedDomElement = dropdownViewport.value?.querySelector(
`[data-select-id="${highlightedItemId.value}"]`
) as HTMLElement;
highlightedDomElement?.scrollIntoView({
block: 'nearest',
inline: 'nearest',
});
}
});
const emit = defineEmits(['update:modelValue', 'changed']); const emit = defineEmits(['update:modelValue', 'changed']);
function setItem(newValue: string | null) { function setItem(newValue: string | null) {
@@ -84,7 +112,6 @@ function moveHighlightDown() {
} }
} }
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => { const highlightedItem = computed(() => {
return props.items.find( return props.items.find(
(item) => props.getKeyFromItem(item) === highlightedItemId.value (item) => props.getKeyFromItem(item) === highlightedItemId.value
@@ -114,7 +141,15 @@ onKeyStroke('Enter', (e) => {
watch(open, () => { watch(open, () => {
if (open.value === true) { if (open.value === true) {
highlightedItemId.value = model.value; nextTick(() => {
const highlightedDomElement = dropdownViewport.value?.querySelector(
`[data-select-id="${model.value}"]`
) as HTMLElement;
dropdownViewport.value?.scrollTo({
top: highlightedDomElement?.offsetTop ?? 0,
behavior: 'instant',
});
});
} }
}); });
</script> </script>
@@ -125,18 +160,28 @@ watch(open, () => {
<slot name="trigger"> </slot> <slot name="trigger"> </slot>
</template> </template>
<template #content> <template #content>
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll"> <div
ref="dropdownViewport"
:class="
twMerge(
'w-60 py-1.5 max-h-60 overflow-y-scroll',
props.class
)
">
<div <div
v-for="item in filteredItems" v-for="item in filteredItems"
:key="props.getKeyFromItem(item) ?? 'none'" :key="props.getKeyFromItem(item) ?? 'none'"
role="option" role="option"
:data-select-id="props.getKeyFromItem(item)"
:value="props.getKeyFromItem(item)" :value="props.getKeyFromItem(item)"
:class="{
'bg-card-background-active':
props.getKeyFromItem(item) === highlightedItemId,
}"
:data-item-id="props.getKeyFromItem(item)"> :data-item-id="props.getKeyFromItem(item)">
<SelectDropdownItem <SelectDropdownItem
@mouseenter="
highlightedItemId = props.getKeyFromItem(item)
"
:highlighted="
props.getKeyFromItem(item) === highlightedItemId
"
:selected="props.getKeyFromItem(item) === model" :selected="props.getKeyFromItem(item) === model"
@click="setItem(props.getKeyFromItem(item))" @click="setItem(props.getKeyFromItem(item))"
:name="props.getNameForItem(item)"></SelectDropdownItem> :name="props.getNameForItem(item)"></SelectDropdownItem>

View File

@@ -4,20 +4,24 @@ import { twMerge } from 'tailwind-merge';
const props = defineProps<{ const props = defineProps<{
name: string; name: string;
selected: boolean; selected: boolean;
highlighted: boolean;
}>(); }>();
</script> </script>
<template> <template>
<div <div class="px-1">
:class=" <div
twMerge( :class="
'flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out cursor-pointer ', twMerge(
props.selected 'flex items-center space-x-3 w-full px-1.5 py-1.5 rounded text-start text-sm font-medium leading-5 text-white focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out cursor-pointer ',
? 'bg-accent-300/20' props.highlighted && 'bg-card-background-active',
: 'hover:bg-card-background-active' props.selected
) ? 'bg-accent-300/20'
"> : 'hover:bg-card-background-active'
<span>{{ name }}</span> )
">
<span>{{ name }}</span>
</div>
</div> </div>
</template> </template>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { twMerge } from 'tailwind-merge';
defineProps<{ const props = defineProps<{
name?: string; name?: string;
class?: string;
}>(); }>();
const input = ref<HTMLInputElement | null>(null); const input = ref<HTMLInputElement | null>(null);
@@ -20,7 +22,12 @@ const model = defineModel();
<template> <template>
<input <input
ref="input" ref="input"
class="border-input-border border bg-input-background text-white focus:ring-input-border-active focus:ring-0 focus-visible:border-input-border-active rounded-md shadow-sm" :class="
twMerge(
'border-input-border border bg-input-background text-white focus:ring-input-border-active focus:ring-0 focus-visible:border-input-border-active rounded-md shadow-sm',
props.class
)
"
v-model="model" v-model="model"
:name="name" /> :name="name" />
</template> </template>

View File

@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { import {
getDayJsInstance, getDayJsInstance,
getLocalizedDayJs, getLocalizedDayJs,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import { twMerge } from 'tailwind-merge';
import { useFocus } from '@vueuse/core'; import { useFocus } from '@vueuse/core';
import { SelectDropdown, TextInput } from '@/packages/ui/src';
import { twMerge } from 'tailwind-merge';
// This has to be a localized timestamp, not UTC // This has to be a localized timestamp, not UTC
const model = defineModel<string | null>({ const model = defineModel<string | null>({
@@ -23,98 +24,156 @@ const props = withDefaults(
} }
); );
const hours = ref( function updateTime(event: Event) {
model.value ? getLocalizedDayJs(model.value).format('HH') : null
);
const minutes = ref(
model.value ? getLocalizedDayJs(model.value).format('mm') : null
);
watch(
() => model.value,
() => {
hours.value = model.value
? getLocalizedDayJs(model.value).format('HH')
: null;
minutes.value = model.value
? getLocalizedDayJs(model.value).format('mm')
: null;
}
);
function updateMinutes(event: Event) {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
const newValue = target.value; const newValue = target.value.trim();
if (!isNaN(parseInt(newValue))) { if (newValue.split(':').length === 2) {
model.value = getDayJsInstance()(model.value) const [hours, minutes] = newValue.split(':');
.set('minutes', Math.min(parseInt(newValue), 59)) if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) {
.format(); model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
.format();
emit('changed', model.value);
}
} }
minutes.value = model.value // check if input is only numbers
? getLocalizedDayJs(model.value).format('mm') else if (/^\d+$/.test(newValue)) {
: null; if (newValue.length === 4) {
// parse 1300 to 13:00
const [hours, minutes] = [
newValue.slice(0, 2),
newValue.slice(2, 4),
];
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
.format();
emit('changed', model.value);
} else if (newValue.length === 3) {
// parse 130 to 01:30
const [hours, minutes] = [
newValue.slice(0, 1),
newValue.slice(1, 3),
];
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
.format();
emit('changed', model.value);
} else if (newValue.length === 2) {
// parse 13 to 13:00
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(newValue), 23))
.set('minutes', 0)
.format();
emit('changed', model.value);
} else if (newValue.length === 1) {
// parse 1 to 01:00
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(newValue), 23))
.set('minutes', 0)
.format();
emit('changed', model.value);
}
}
inputValue.value = getLocalizedDayJs(model.value).format('HH:mm');
} }
function updateHours(event: Event) { watch(model, (value) => {
const target = event.target as HTMLInputElement; inputValue.value = value ? getLocalizedDayJs(value).format('HH:mm') : null;
const newValue = target.value; });
if (newValue.endsWith(':')) {
minutesInput.value?.focus();
} else if (!isNaN(parseInt(newValue))) {
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(newValue), 23))
.format();
}
hours.value = model.value
? getLocalizedDayJs(model.value).format('HH')
: null;
}
const hoursInput = ref<HTMLInputElement | null>(null); const timeInput = ref<HTMLInputElement | null>(null);
const minutesInput = ref<HTMLInputElement | null>(null);
const emit = defineEmits(['changed']); const emit = defineEmits(['changed']);
useFocus(hoursInput, { initialValue: props.focus }); useFocus(timeInput, { initialValue: props.focus });
type TimeOption = {
timestamp: string;
name: string;
};
const getStartOptions = computed<TimeOption[]>(() => {
// options for the entire day in 15 minute intervals
const options = [];
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
const timestamp = getLocalizedDayJs(model.value)
.set('hour', hour)
.set('minute', minute)
.format();
const name = getLocalizedDayJs(model.value)
.set('hour', hour)
.set('minute', minute)
.format('HH:mm');
options.push({ timestamp, name });
}
}
return options;
});
const inputValue = ref(
model.value ? getLocalizedDayJs(model.value).format('HH:mm') : null
);
const open = ref(false);
const closestValue = computed({
get() {
const target = getDayJsInstance()(model.value);
let closestDiff: number | null = null;
let closest = target;
for (const option of getStartOptions.value) {
const diff = Math.abs(
getDayJsInstance()(option.timestamp).diff(target)
);
if (closestDiff === null || diff < closestDiff) {
closestDiff = diff;
closest = getDayJsInstance()(option.timestamp);
}
}
return closest.format();
},
set(value: string) {
model.value = value;
emit('changed', model.value);
},
});
</script> </script>
<template> <template>
<div class="flex items-center justify-center text-white"> <div class="flex min-w-0 items-center justify-center text-white">
<div <SelectDropdown
:class=" :class="twMerge('mine-w-0 w-24', size === 'large' && 'w-28')"
twMerge( v-model="closestValue"
'border bg-input-background rounded-md border-input-border overflow-hidden', v-model:open="open"
props.size === 'large' ? 'py-1.5 px-2' : '' :get-key-from-item="(item: TimeOption) => item.timestamp"
) :get-name-for-item="(item: TimeOption) => item.name"
"> :items="getStartOptions">
<input <template #trigger>
v-model="hours" <TextInput
ref="hoursInput" v-model="inputValue"
@input="updateHours" ref="timeInput"
@keydown.enter="emit('changed')" :class="
@focus="($event.target as HTMLInputElement).select()" twMerge(
data-testid="time_picker_hour" 'text-center w-24 px-3 py-2',
type="text" size === 'large' && 'w-28'
:class=" )
twMerge( "
'border-none bg-transparent px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active', @blur="updateTime"
props.size === 'large' ? 'text-base' : 'text-sm' @keydown.enter="
) updateTime($event);
" /> open = false;
<span>:</span> "
<input @keydown.tab="open = false"
v-model="minutes" @focus="($event.target as HTMLInputElement).select()"
ref="minutesInput" @mouseup="($event.target as HTMLInputElement).select()"
@keydown.enter="emit('changed')" @click="($event.target as HTMLInputElement).select()"
@input="updateMinutes" @pointerup="($event.target as HTMLInputElement).select()"
@focus="($event.target as HTMLInputElement).select()" @focusin="open = true"
data-testid="time_picker_minute" data-testid="time_picker_input"
type="text" type="text" />
:class=" </template>
twMerge( </SelectDropdown>
'border-none bg-transparent px-1 py-1 w-[30px] text-center focus:ring-0 focus:bg-card-background-active',
props.size === 'large' ? 'text-base' : 'text-sm'
)
" />
</div>
</div> </div>
</template> </template>

View File

@@ -57,27 +57,27 @@ watch(focused, (newValue, oldValue) => {
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2"> class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2">
<div class="px-2"> <div class="px-2">
<div class="font-bold text-white text-sm pb-2">Start</div> <div class="font-bold text-white text-sm pb-2">Start</div>
<div class="space-y-1"> <div class="space-y-2">
<TimePicker <TimePicker
data-testid="time_entry_range_start" data-testid="time_entry_range_start"
:focus :focus
@changed="updateTimeEntry" @changed="updateTimeEntry"
v-model="tempStart"></TimePicker> v-model="tempStart"></TimePicker>
<DatePicker <DatePicker
class="text-sm px-2 py-1" class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
@changed="updateTimeEntry" @changed="updateTimeEntry"
v-model="tempStart"></DatePicker> v-model="tempStart"></DatePicker>
</div> </div>
</div> </div>
<div class="px-2"> <div class="px-2">
<div class="font-bold text-white text-sm pb-2">End</div> <div class="font-bold text-white text-sm pb-2">End</div>
<div v-if="tempEnd !== null" class="space-y-1"> <div v-if="tempEnd !== null" class="space-y-2">
<TimePicker <TimePicker
data-testid="time_entry_range_end" data-testid="time_entry_range_end"
@changed="updateTimeEntry" @changed="updateTimeEntry"
v-model="tempEnd"></TimePicker> v-model="tempEnd"></TimePicker>
<DatePicker <DatePicker
class="text-sm px-2 py-1" class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
@changed="updateTimeEntry" @changed="updateTimeEntry"
v-model="tempEnd"></DatePicker> v-model="tempEnd"></DatePicker>
</div> </div>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, watch } from 'vue'; import { computed, onMounted, onUnmounted, watch } from 'vue';
import { useId } from 'radix-vue';
import { isLastLayer, layers } from '@/packages/ui/src/utils/dismissableLayer';
const props = defineProps({ const props = defineProps({
show: { show: {
@@ -34,20 +36,35 @@ const close = () => {
emit('close'); emit('close');
} }
}; };
const id = useId();
const closeOnEscape = (e: KeyboardEvent) => { const closeOnEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.show) { if (isLastLayer(id)) {
close(); if (e.key === 'Escape' && props.show) {
close();
}
} }
}; };
onMounted(() => document.addEventListener('keydown', closeOnEscape)); onMounted(() => {
document.addEventListener('keydown', closeOnEscape);
});
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape); document.removeEventListener('keydown', closeOnEscape);
document.body.style.overflow = 'visible'; document.body.style.overflow = 'visible';
}); });
watch(
() => props.show,
(value) => {
if (value) {
layers.value.push(id);
} else {
layers.value = layers.value.filter((layer) => layer !== id);
}
}
);
const maxWidthClass = computed(() => { const maxWidthClass = computed(() => {
return { return {
sm: 'sm:max-w-sm', sm: 'sm:max-w-sm',

View File

@@ -19,7 +19,6 @@ import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue'; import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue'; import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
import type { Client } from '@/packages/api/src'; import type { Client } from '@/packages/api/src';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = ref(false); const saving = ref(false);
@@ -29,6 +28,7 @@ const props = defineProps<{
createProject: (project: CreateProjectBody) => Promise<Project | undefined>; createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string; currency: string;
enableEstimatedTime: boolean;
}>(); }>();
const activeClients = computed(() => { const activeClients = computed(() => {
@@ -138,7 +138,7 @@ const currentClientName = computed(() => {
</div> </div>
<div> <div>
<EstimatedTimeSection <EstimatedTimeSection
v-if="isAllowedToPerformPremiumAction()" v-if="enableEstimatedTime"
@submit="submit()" @submit="submit()"
v-model="project.estimated_time"></EstimatedTimeSection> v-model="project.estimated_time"></EstimatedTimeSection>
</div> </div>

View File

@@ -69,7 +69,7 @@ const emit = defineEmits(['submit']);
<div <div
class="sm:max-w-[120px]" class="sm:max-w-[120px]"
v-if="billableRateSelect === 'custom-rate'"> v-if="billableRateSelect === 'custom-rate'">
<InputLabel for="billableRate" value="Billable Rate" /> <InputLabel for="billableRate" value="Billable Rate" class="mb-2" />
<BillableRateInput <BillableRateInput
@keydown.enter="emit('submit')" @keydown.enter="emit('submit')"
:currency="currency" :currency="currency"

View File

@@ -39,6 +39,8 @@ const props = defineProps<{
deleteTimeEntries: (timeEntries: TimeEntry[]) => void; deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
currency: string; currency: string;
selectedTimeEntries: TimeEntry[]; selectedTimeEntries: TimeEntry[];
enableEstimatedTime: boolean;
canCreateProject: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
selected: [TimeEntry[]]; selected: [TimeEntry[]];
@@ -120,11 +122,13 @@ function onSelectChange(event: Event) {
:clients :clients
:createProject :createProject
:createClient :createClient
:canCreateProject
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
:showBadgeBorder="false" :showBadgeBorder="false"
@changed="updateProjectAndTask" @changed="updateProjectAndTask"
:project="timeEntry.project_id" :project="timeEntry.project_id"
:enableEstimatedTime
:currency="currency" :currency="currency"
:task=" :task="
timeEntry.task_id timeEntry.task_id
@@ -174,10 +178,13 @@ function onSelectChange(event: Event) {
class="w-full border-t border-default-background-separator bg-black/15"> class="w-full border-t border-default-background-separator bg-black/15">
<TimeEntryRow <TimeEntryRow
:projects="projects" :projects="projects"
:enableEstimatedTime
:canCreateProject
:tasks="tasks" :tasks="tasks"
:selected=" :selected="
!!selectedTimeEntries.find( !!selectedTimeEntries.find(
(filterEntry) => filterEntry.id === subEntry.id (filterEntry: TimeEntry) =>
filterEntry.id === subEntry.id
) )
" "
@selected="emit('selected', [subEntry])" @selected="emit('selected', [subEntry])"

View File

@@ -0,0 +1,269 @@
<script setup lang="ts">
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, nextTick, ref, watch } from 'vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { TagIcon } from '@heroicons/vue/20/solid';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import type {
CreateClientBody,
CreateProjectBody,
Project,
Client,
CreateTimeEntryBody,
} from '@/packages/api/src';
import TimePicker from '@/packages/ui/src/Input/TimePicker.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { Badge } from '@/packages/ui/src';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
import type { Tag, Task } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const saving = ref(false);
const props = defineProps<{
enableEstimatedTime: boolean;
createTimeEntry: (
entry: Omit<CreateTimeEntryBody, 'member_id'>
) => Promise<void>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
tags: Tag[];
projects: Project[];
tasks: Task[];
clients: Client[];
currency: string;
canCreateProject: boolean;
}>();
const description = ref<HTMLInputElement | null>(null);
watch(show, (value) => {
if (value) {
nextTick(() => {
description.value?.focus();
});
}
});
const timeEntryDefaultValues = {
description: '',
project_id: null,
task_id: null,
tags: [],
billable: false,
start: getDayJsInstance().utc().subtract(1, 'h').format(),
end: getDayJsInstance().utc().format(),
};
const timeEntry = ref({ ...timeEntryDefaultValues });
const localStart = ref(
getLocalizedDayJs(timeEntryDefaultValues.start).format()
);
const localEnd = ref(getLocalizedDayJs(timeEntryDefaultValues.end).format());
watch(localStart, (value) => {
timeEntry.value.start = getLocalizedDayJs(value).utc().format();
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
localEnd.value = value;
}
});
watch(localEnd, (value) => {
timeEntry.value.end = getLocalizedDayJs(value).utc().format();
});
async function submit() {
await props.createTimeEntry({ ...timeEntry.value });
timeEntry.value = { ...timeEntryDefaultValues };
localStart.value = getLocalizedDayJs(timeEntryDefaultValues.start).format();
localEnd.value = getLocalizedDayJs(timeEntryDefaultValues.end).format();
show.value = false;
}
const billableProxy = computed({
get: () => (timeEntry.value.billable ? 'true' : 'false'),
set: (value: string) => {
timeEntry.value.billable = value === 'true';
},
});
type BillableOption = {
label: string;
value: string;
};
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create manual time entry </span>
</div>
</template>
<template #content>
<div class="sm:flex items-end space-y-2 sm:space-y-0 sm:space-x-4">
<div class="flex-1">
<TextInput
id="description"
ref="description"
placeholder="What did you work on?"
v-model="timeEntry.description"
@keydown.enter="submit"
type="text"
class="mt-1 block w-full" />
</div>
</div>
<div
class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
<div class="flex w-full items-center space-x-2 justify-between">
<div class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
:clients
:createProject
:createClient
:canCreateProject
:currency
size="xlarge"
class="bg-input-background"
:projects="projects"
:tasks="tasks"
:enableEstimatedTime="enableEstimatedTime"
v-model:project="timeEntry.project_id"
v-model:task="
timeEntry.task_id
"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center space-x-2">
<div class="flex-col">
<TagDropdown
:createTag
v-model="timeEntry.tags"
:tags="tags">
<template v-slot:trigger>
<Badge
class="bg-input-background"
tag="button"
size="xlarge">
<TagIcon
v-if="timeEntry.tags.length === 0"
tag="button"
class="w-4"></TagIcon>
<div
v-else
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
{{ timeEntry.tags.length }}
</div>
<span>Tags</span>
</Badge>
</template>
</TagDropdown>
</div>
<div class="flex-col">
<SelectDropdown
v-model="billableProxy"
:get-key-from-item="
(item: BillableOption) => item.value
"
:get-name-for-item="
(item: BillableOption) => item.label
"
:items="[
{
label: 'Billable',
value: 'true',
},
{
label: 'Non Billable',
value: 'false',
},
]">
<template v-slot:trigger>
<Badge
class="bg-input-background"
tag="button"
size="xlarge">
<BillableIcon
class="h-4"></BillableIcon>
<span>{{
timeEntry.billable
? 'Billable'
: 'Non-Billable'
}}</span>
</Badge>
</template>
</SelectDropdown>
</div>
</div>
</div>
</div>
<div class="flex pt-4 space-x-4">
<div class="flex-1">
<InputLabel>Duration</InputLabel>
<div class="space-y-2 mt-1 flex flex-col">
<DurationHumanInput
v-model:start="localStart"
v-model:end="localEnd"></DurationHumanInput>
<div class="text-sm flex space-x-1">
<InformationCircleIcon
class="w-4 text-text-quaternary"></InformationCircleIcon>
<span class="text-text-secondary text-xs">
You can type natural language here f.e.
<span class="font-semibold"> 2h 30m</span>
</span>
</div>
</div>
</div>
<div class="">
<InputLabel>Start</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePicker
size="large"
v-model="localStart"></TimePicker>
<DatePicker
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"
v-model="localStart"></DatePicker>
</div>
</div>
<div class="">
<InputLabel>End</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePicker
size="large"
v-model="localEnd"></TimePicker>
<DatePicker
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"
v-model="localEnd"></DatePicker>
</div>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Create Time Entry
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -37,6 +37,8 @@ const props = defineProps<{
createProject: (project: CreateProjectBody) => Promise<Project | undefined>; createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string; currency: string;
enableEstimatedTime: boolean;
canCreateProject: boolean;
}>(); }>();
const groupedTimeEntries = computed(() => { const groupedTimeEntries = computed(() => {
@@ -113,6 +115,7 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
end: null, end: null,
billable: entry.billable, billable: entry.billable,
description: entry.description, description: entry.description,
tags: [...entry.tags],
}); });
} }
function sumDuration(timeEntries: TimeEntry[]) { function sumDuration(timeEntries: TimeEntry[]) {
@@ -152,16 +155,18 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
@select-all="selectAllTimeEntries(value)" @select-all="selectAllTimeEntries(value)"
@unselect-all="unselectAllTimeEntries(value)" @unselect-all="unselectAllTimeEntries(value)"
:checked=" :checked="
value.every((timeEntry) => value.every((timeEntry: TimeEntry) =>
selectedTimeEntries.includes(timeEntry) selectedTimeEntries.includes(timeEntry)
) )
"></TimeEntryRowHeading> "></TimeEntryRowHeading>
<template v-for="entry in value" :key="entry.id"> <template v-for="entry in value" :key="entry.id">
<TimeEntryAggregateRow <TimeEntryAggregateRow
:createProject :createProject
:canCreateProject
:enableEstimatedTime
:selected-time-entries="selectedTimeEntries" :selected-time-entries="selectedTimeEntries"
@selected=" @selected="
(timeEntries) => { (timeEntries: TimeEntry[]) => {
selectedTimeEntries = [ selectedTimeEntries = [
...selectedTimeEntries, ...selectedTimeEntries,
...timeEntries, ...timeEntries,
@@ -169,11 +174,12 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
} }
" "
@unselected=" @unselected="
(timeEntriesToUnselect) => { (timeEntriesToUnselect: TimeEntry[]) => {
selectedTimeEntries = selectedTimeEntries.filter( selectedTimeEntries = selectedTimeEntries.filter(
(item) => (item: TimeEntry) =>
!timeEntriesToUnselect.find( !timeEntriesToUnselect.find(
(filterEntry) => filterEntry.id === item.id (filterEntry: TimeEntry) =>
filterEntry.id === item.id
) )
); );
} }
@@ -193,17 +199,19 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
:time-entry="entry"></TimeEntryAggregateRow> :time-entry="entry"></TimeEntryAggregateRow>
<TimeEntryRow <TimeEntryRow
:createClient :createClient
:enableEstimatedTime
:canCreateProject
:createProject :createProject
:projects="projects" :projects="projects"
:selected=" :selected="
!!selectedTimeEntries.find( !!selectedTimeEntries.find(
(filterEntry) => filterEntry.id === entry.id (filterEntry: TimeEntry) => filterEntry.id === entry.id
) )
" "
@selected="selectedTimeEntries.push(entry)" @selected="selectedTimeEntries.push(entry)"
@unselected=" @unselected="
selectedTimeEntries = selectedTimeEntries.filter( selectedTimeEntries = selectedTimeEntries.filter(
(item) => item.id !== entry.id (item: TimeEntry) => item.id !== entry.id
) )
" "
:tasks="tasks" :tasks="tasks"

View File

@@ -1,8 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid'; import { PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';
import TimeEntryMassUpdateModal from '@/Components/Common/TimeEntry/TimeEntryMassUpdateModal.vue'; import TimeEntryMassUpdateModal from '@/packages/ui/src/TimeEntry/TimeEntryMassUpdateModal.vue';
import type { TimeEntry } from '@/packages/api/src'; import type {
Client,
CreateClientBody,
CreateProjectBody,
Project,
Tag,
Task,
TimeEntry,
UpdateMultipleTimeEntriesChangeset,
} from '@/packages/api/src';
import { ref } from 'vue'; import { ref } from 'vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { Checkbox, InputLabel } from '@/packages/ui/src'; import { Checkbox, InputLabel } from '@/packages/ui/src';
@@ -12,6 +21,19 @@ const props = defineProps<{
deleteSelected: () => void; deleteSelected: () => void;
class?: string; class?: string;
allSelected: boolean; allSelected: boolean;
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
updateTimeEntries: (
changeset: UpdateMultipleTimeEntriesChangeset
) => Promise<void>;
currency: string;
enableEstimatedTime: boolean;
canCreateProject: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -25,6 +47,17 @@ const showMassUpdateModal = ref(false);
<template> <template>
<TimeEntryMassUpdateModal <TimeEntryMassUpdateModal
:projects
:tasks
:tags
:clients
:createTag
:createProject
:createClient
:updateTimeEntries
:enableEstimatedTime
:canCreateProject
:currency
:time-entries="selectedTimeEntries" :time-entries="selectedTimeEntries"
@submit="emit('submit')" @submit="emit('submit')"
v-model:show="showMassUpdateModal"></TimeEntryMassUpdateModal> v-model:show="showMassUpdateModal"></TimeEntryMassUpdateModal>

View File

@@ -1,66 +1,50 @@
<script setup lang="ts"> <script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue'; import TextInput from '../Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '../Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue'; import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '../Buttons/PrimaryButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue'; import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue'; import InputLabel from '../Input/InputLabel.vue';
import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects';
import { useTagsStore } from '@/utils/useTags';
import { import {
type CreateClientBody, type CreateClientBody,
type CreateProjectBody, type CreateProjectBody,
type Project, type Project,
type Client, type Client,
api,
type TimeEntry, type TimeEntry,
type UpdateMultipleTimeEntriesChangeset, type UpdateMultipleTimeEntriesChangeset,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money';
import { Badge, Checkbox } from '@/packages/ui/src'; import { Badge, Checkbox } from '@/packages/ui/src';
import SelectDropdown from '../../../packages/ui/src/Input/SelectDropdown.vue'; import SelectDropdown from '../Input/SelectDropdown.vue';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
const projectStore = useProjectsStore(); import type { Tag, Task } from '@/packages/api/src';
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
const clientStore = useClientsStore();
const { clients } = storeToRefs(clientStore);
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = ref(false); const saving = ref(false);
async function createProject(
project: CreateProjectBody
): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
const props = defineProps<{ const props = defineProps<{
timeEntries: TimeEntry[]; timeEntries: TimeEntry[];
projects: Project[];
tasks: Task[];
clients: Client[];
tags: Tag[];
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
updateTimeEntries: (
changeset: UpdateMultipleTimeEntriesChangeset
) => Promise<void>;
currency: string;
enableEstimatedTime: boolean;
canCreateProject: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
submit: []; submit: [];
}>(); }>();
async function createClient(
body: CreateClientBody
): Promise<Client | undefined> {
return await useClientsStore().createClient(body);
}
const descriptionInput = ref<HTMLInputElement | null>(null); const descriptionInput = ref<HTMLInputElement | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
watch(show, (value) => { watch(show, (value) => {
if (value) { if (value) {
nextTick(() => { nextTick(() => {
@@ -75,11 +59,6 @@ const projectId = ref<string | null>(null);
const billable = ref<boolean | undefined>(undefined); const billable = ref<boolean | undefined>(undefined);
const selectedTags = ref<string[]>([]); const selectedTags = ref<string[]>([]);
const { tags } = storeToRefs(useTagsStore());
async function createTag(tag: string) {
return await useTagsStore().createTag(tag);
}
const timeEntryBillable = computed({ const timeEntryBillable = computed({
get: () => { get: () => {
if (billable.value === undefined) { if (billable.value === undefined) {
@@ -99,71 +78,48 @@ const timeEntryBillable = computed({
}); });
async function submit() { async function submit() {
const organizationId = getCurrentOrganizationId();
saving.value = true; saving.value = true;
if (organizationId) { const timeEntryUpdatesBody = {} as UpdateMultipleTimeEntriesChangeset;
const timeEntryUpdatesBody = {} as UpdateMultipleTimeEntriesChangeset; if (description.value && description.value !== '') {
if (description.value && description.value !== '') { timeEntryUpdatesBody.description = description.value;
timeEntryUpdatesBody.description = description.value; }
if (projectId.value !== null) {
if (projectId.value === '') {
// "No Project" is selected
timeEntryUpdatesBody.project_id = null;
} else {
timeEntryUpdatesBody.project_id = projectId.value;
} }
if (projectId.value !== null) { timeEntryUpdatesBody.task_id = null;
if (projectId.value === '') { if (taskId.value !== undefined) {
// "No Project" is selected timeEntryUpdatesBody.task_id = taskId.value;
timeEntryUpdatesBody.project_id = null;
} else {
timeEntryUpdatesBody.project_id = projectId.value;
}
timeEntryUpdatesBody.task_id = null;
if (taskId.value !== undefined) {
timeEntryUpdatesBody.task_id = taskId.value;
}
} }
}
if (billable.value !== undefined) { if (billable.value !== undefined) {
timeEntryUpdatesBody.billable = billable.value; timeEntryUpdatesBody.billable = billable.value;
} }
if (selectedTags.value.length > 0) { if (selectedTags.value.length > 0) {
timeEntryUpdatesBody.tags = selectedTags.value; timeEntryUpdatesBody.tags = selectedTags.value;
} }
if (removeAllTags.value) { if (removeAllTags.value) {
timeEntryUpdatesBody.tags = []; timeEntryUpdatesBody.tags = [];
} }
try { try {
await handleApiRequestNotifications( await props.updateTimeEntries({ ...timeEntryUpdatesBody });
() =>
api.updateMultipleTimeEntries( show.value = false;
{ emit('submit');
ids: props.timeEntries.map( description.value = '';
(timeEntry) => timeEntry.id projectId.value = null;
), taskId.value = undefined;
changes: { selectedTags.value = [];
...timeEntryUpdatesBody, billable.value = undefined;
}, saving.value = false;
}, removeAllTags.value = false;
{ } catch (e) {
params: { saving.value = false;
organization: organizationId,
},
}
),
'Time entries updated',
'Failed to update time entries',
() => {
show.value = false;
emit('submit');
description.value = '';
projectId.value = null;
taskId.value = undefined;
selectedTags.value = [];
billable.value = undefined;
saving.value = false;
removeAllTags.value = false;
}
);
} catch (e) {
saving.value = false;
}
} }
} }
const removeAllTags = ref(false); const removeAllTags = ref(false);
@@ -172,6 +128,7 @@ watch(removeAllTags, () => {
selectedTags.value = []; selectedTags.value = [];
} }
}); });
type SelectOption = { label: string; value: string };
</script> </script>
<template> <template>
@@ -200,11 +157,13 @@ watch(removeAllTags, () => {
:clients :clients
:createProject :createProject
:createClient :createClient
:currency="getOrganizationCurrencyString()" :currency="currency"
:canCreateProject
class="mt-1" class="mt-1"
empty-placeholder="Select project..." empty-placeholder="Select project..."
allow-reset allow-reset
size="xlarge" size="xlarge"
:enableEstimatedTime
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
v-model:project="projectId" v-model:project="projectId"
@@ -242,8 +201,12 @@ watch(removeAllTags, () => {
<div class="flex"> <div class="flex">
<SelectDropdown <SelectDropdown
v-model="timeEntryBillable" v-model="timeEntryBillable"
:get-key-from-item="(item) => item.value" :get-key-from-item="
:get-name-for-item="(item) => item.label" (item: SelectOption) => item.value
"
:get-name-for-item="
(item: SelectOption) => item.label
"
:items="[ :items="[
{ {
label: 'Keep current billable status', label: 'Keep current billable status',

View File

@@ -3,6 +3,7 @@ import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { defineProps, ref } from 'vue'; import { defineProps, ref } from 'vue';
import { formatDate, formatStartEnd } from '@/packages/ui/src/utils/time'; import { formatDate, formatStartEnd } from '@/packages/ui/src/utils/time';
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue'; import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
import { twMerge } from 'tailwind-merge';
defineProps<{ defineProps<{
start: string; start: string;
@@ -27,11 +28,15 @@ const open = ref(false);
<template #trigger> <template #trigger>
<button <button
data-testid="time_entry_range_selector" data-testid="time_entry_range_selector"
class="text-muted w-[110px] px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80" :class="
:class="{ twMerge(
'text-sm py-2 font-medium': !showDate, 'text-muted w-[110px] px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80',
'text-xs py-1.5 font-semibold': showDate, showDate
}"> ? 'text-xs py-1.5 font-semibold'
: 'text-sm py-2 font-medium',
open && 'border-card-border bg-card-background'
)
">
{{ formatStartEnd(start, end) }} {{ formatStartEnd(start, end) }}
<span v-if="showDate" class="text-text-tertiary font-medium" <span v-if="showDate" class="text-text-tertiary font-medium"
>{{ formatDate(start) }} >{{ formatDate(start) }}

View File

@@ -38,6 +38,8 @@ const props = defineProps<{
showMember?: boolean; showMember?: boolean;
showDate?: boolean; showDate?: boolean;
selected?: boolean; selected?: boolean;
canCreateProject: boolean;
enableEstimatedTime: boolean;
}>(); }>();
const emit = defineEmits<{ selected: []; unselected: [] }>(); const emit = defineEmits<{ selected: []; unselected: [] }>();
@@ -110,6 +112,7 @@ function onSelectChange(event: Event) {
<TimeTrackerProjectTaskDropdown <TimeTrackerProjectTaskDropdown
:createProject :createProject
:createClient :createClient
:canCreateProject
:clients :clients
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
@@ -117,6 +120,7 @@ function onSelectChange(event: Event) {
@changed="updateProjectAndTask" @changed="updateProjectAndTask"
:project="timeEntry.project_id" :project="timeEntry.project_id"
:currency="currency" :currency="currency"
:enableEstimatedTime
:task=" :task="
timeEntry.task_id timeEntry.task_id
"></TimeTrackerProjectTaskDropdown> "></TimeTrackerProjectTaskDropdown>

View File

@@ -71,8 +71,9 @@ function selectInput(event: Event) {
<template #trigger> <template #trigger>
<input <input
data-testid="time_entry_duration_input" data-testid="time_entry_duration_input"
class="text-white w-[100px] px-3 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold" class="text-white w-[100px] px-3 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-white/80"
@focus="selectInput" @focus="selectInput"
@keydown.tab="open = false"
@blur="updateTimerAndStartLiveTimerUpdate" @blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="updateTimerAndStartLiveTimerUpdate" @keydown.enter="updateTimerAndStartLiveTimerUpdate"
v-model="currentTime" /> v-model="currentTime" />

View File

@@ -33,6 +33,8 @@ const props = defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
isActive: boolean; isActive: boolean;
currency: string; currency: string;
enableEstimatedTime: boolean;
canCreateProject: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -92,7 +94,7 @@ function updateTimeEntryDescription() {
class="flex items-center relative @container" class="flex items-center relative @container"
data-testid="dashboard_timer"> data-testid="dashboard_timer">
<div <div
class="flex flex-col lg:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card"> class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
<div class="flex flex-1 items-center pr-6"> <div class="flex flex-1 items-center pr-6">
<input <input
placeholder="What are you working on?" placeholder="What are you working on?"
@@ -101,26 +103,28 @@ function updateTimeEntryDescription() {
v-model="tempDescription" v-model="tempDescription"
@keydown.enter="startTimerIfNotActive" @keydown.enter="startTimerIfNotActive"
@blur="updateTimeEntryDescription" @blur="updateTimeEntryDescription"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator lg:px-4 text-base @4xl:text-lg text-white font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition" class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base @4xl:text-lg text-white font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition"
type="text" /> type="text" />
</div> </div>
<div class="flex items-center justify-between pl-2 shrink min-w-0"> <div class="flex items-center justify-between pl-2 shrink min-w-0">
<div <div
class="flex items-center w-[130px] sm:w-auto shrink min-w-0"> class="flex items-center w-[130px] @2xl:w-auto shrink min-w-0">
<TimeTrackerProjectTaskDropdown <TimeTrackerProjectTaskDropdown
:createClient :createClient
:canCreateProject
:clients :clients
:createProject :createProject
:currency="currency" :currency="currency"
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
@changed="updateProject" @changed="updateProject"
:enableEstimatedTime="enableEstimatedTime"
v-model:project="currentTimeEntry.project_id" v-model:project="currentTimeEntry.project_id"
v-model:task=" v-model:task="
currentTimeEntry.task_id currentTimeEntry.task_id
"></TimeTrackerProjectTaskDropdown> "></TimeTrackerProjectTaskDropdown>
</div> </div>
<div class="flex items-center lg:space-x-2 px-2 lg:px-4"> <div class="flex items-center @2xl:space-x-2 px-2 @2xl:px-4">
<TimeTrackerTagDropdown <TimeTrackerTagDropdown
@changed="$emit('updateTimeEntry')" @changed="$emit('updateTimeEntry')"
:createTag :createTag
@@ -149,7 +153,7 @@ function updateTimeEntryDescription() {
</div> </div>
</div> </div>
<div <div
class="pl-4 lg:pl-6 pr-3 absolute sm:relative top-[6px] sm:top-0 right-0"> class="pl-4 @2xl:pl-6 pr-3 absolute sm:relative top-[6px] sm:top-0 right-0">
<TimeTrackerStartStop <TimeTrackerStartStop
:active="isActive" :active="isActive"
@changed="onToggleButtonPress" @changed="onToggleButtonPress"

View File

@@ -19,6 +19,7 @@ import {
XMarkIcon, XMarkIcon,
} from '@heroicons/vue/16/solid'; } from '@heroicons/vue/16/solid';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue'; import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import { twMerge } from 'tailwind-merge';
const task = defineModel<string | null>('task', { const task = defineModel<string | null>('task', {
default: null, default: null,
@@ -63,6 +64,9 @@ const props = withDefaults(
currency: string; currency: string;
emptyPlaceholder: string; emptyPlaceholder: string;
allowReset: boolean; allowReset: boolean;
enableEstimatedTime: boolean;
canCreateProject: boolean;
class?: string;
}>(), }>(),
{ {
showBadgeBorder: true, showBadgeBorder: true,
@@ -531,7 +535,7 @@ const showCreateProject = ref(false);
</script> </script>
<template> <template>
<div v-if="projects.length === 0"> <div v-if="projects.length === 0 && canCreateProject">
<Badge <Badge
@click="showCreateProject = true" @click="showCreateProject = true"
size="large" size="large"
@@ -550,7 +554,12 @@ const showCreateProject = ref(false);
:border="showBadgeBorder" :border="showBadgeBorder"
tag="button" tag="button"
:name="selectedProjectName" :name="selectedProjectName"
class="focus:border-border-tertiary w-full focus:outline-0 focus:bg-card-background-separator min-w-0 relative"> :class="
twMerge(
'focus:border-border-tertiary w-full focus:outline-0 focus:bg-card-background-separator min-w-0 relative',
props.class
)
">
<div class="flex items-center lg:space-x-1 min-w-0"> <div class="flex items-center lg:space-x-1 min-w-0">
<span class="whitespace-nowrap text-xs lg:text-sm"> <span class="whitespace-nowrap text-xs lg:text-sm">
{{ selectedProjectName }} {{ selectedProjectName }}
@@ -688,7 +697,9 @@ const showCreateProject = ref(false);
</template> </template>
</template> </template>
</div> </div>
<div class="hover:bg-card-background-active rounded-b-lg"> <div
v-if="canCreateProject"
class="hover:bg-card-background-active rounded-b-lg">
<button <button
@click=" @click="
open = false; open = false;
@@ -704,6 +715,7 @@ const showCreateProject = ref(false);
</Dropdown> </Dropdown>
<ProjectCreateModal <ProjectCreateModal
:createClient :createClient
:enableEstimatedTime="enableEstimatedTime"
:currency="currency" :currency="currency"
:clients="clients" :clients="clients"
:createProject :createProject

View File

@@ -25,6 +25,9 @@ import SelectDropdown from './Input/SelectDropdown.vue';
import Badge from './Badge.vue'; import Badge from './Badge.vue';
import Checkbox from './Input/Checkbox.vue'; import Checkbox from './Input/Checkbox.vue';
import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue'; import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';
import TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
export { export {
money, money,
@@ -46,4 +49,7 @@ export {
Badge, Badge,
Checkbox, Checkbox,
TimeEntryGroupedTable, TimeEntryGroupedTable,
TimeEntryMassActionRow,
MoreOptionsDropdown,
TimeEntryCreateModal,
}; };

View File

@@ -0,0 +1,7 @@
import { ref } from 'vue';
export const layers = ref<string[]>([]);
export function isLastLayer(id: string) {
return layers.value[layers.value.length - 1] === id;
}

1
resources/js/types/reporting.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export type ExportFormat = 'xlsx' | 'csv' | 'ods' | 'pdf';

View File

@@ -9,7 +9,6 @@ import { canViewClients, canViewMembers } from '@/utils/permissions';
export function initializeStores() { export function initializeStores() {
refreshStores(); refreshStores();
useTimeEntriesStore().fetchTimeEntries();
} }
export function refreshStores() { export function refreshStores() {
@@ -17,6 +16,7 @@ export function refreshStores() {
useTasksStore().fetchTasks(); useTasksStore().fetchTasks();
useTagsStore().fetchTags(); useTagsStore().fetchTags();
useCurrentTimeEntryStore().fetchCurrentTimeEntry(); useCurrentTimeEntryStore().fetchCurrentTimeEntry();
useTimeEntriesStore().patchTimeEntries();
if (canViewMembers()) { if (canViewMembers()) {
useMembersStore().fetchMembers(); useMembersStore().fetchMembers();
} }

View File

@@ -17,7 +17,7 @@ dayjs.extend(utc);
const emptyTimeEntry = { const emptyTimeEntry = {
id: '', id: '',
description: null, description: '',
user_id: '', user_id: '',
start: '', start: '',
end: null, end: null,

View File

@@ -13,6 +13,7 @@ import {
} from '@/packages/api/src'; } from '@/packages/api/src';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
export const useTimeEntriesStore = defineStore('timeEntries', () => { export const useTimeEntriesStore = defineStore('timeEntries', () => {
const timeEntries = ref<TimeEntry[]>(reactive([])); const timeEntries = ref<TimeEntry[]>(reactive([]));
@@ -20,6 +21,39 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
const allTimeEntriesLoaded = ref(false); const allTimeEntriesLoaded = ref(false);
const { handleApiRequestNotifications } = useNotificationsStore(); const { handleApiRequestNotifications } = useNotificationsStore();
async function patchTimeEntries(
queryParams: TimeEntriesQueryParams = {
only_full_dates: 'true',
member_id: getCurrentMembershipId(),
}
) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
queries: queryParams,
}),
undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data) {
// insert missing time entries
const missingTimeEntries = timeEntriesResponse.data.filter(
(entry) => !timeEntries.value.find((e) => e.id === entry.id)
);
timeEntries.value = [
...missingTimeEntries,
...timeEntries.value,
];
}
}
}
async function fetchTimeEntries( async function fetchTimeEntries(
queryParams: TimeEntriesQueryParams = { queryParams: TimeEntriesQueryParams = {
only_full_dates: 'true', only_full_dates: 'true',
@@ -83,7 +117,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
async function updateTimeEntries( async function updateTimeEntries(
ids: string[], ids: string[],
changes: Partial<TimeEntry> changes: UpdateMultipleTimeEntriesChangeset
) { ) {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (organizationId) { if (organizationId) {
@@ -199,5 +233,6 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
allTimeEntriesLoaded, allTimeEntriesLoaded,
updateTimeEntries, updateTimeEntries,
deleteTimeEntries, deleteTimeEntries,
patchTimeEntries,
}; };
}); });

View File

@@ -0,0 +1,154 @@
@use('App\Enums\ExportFormat')
@use('Brick\Math\BigDecimal')
@use('PhpOffice\PhpSpreadsheet\Cell\DataType')
@use('PhpOffice\PhpSpreadsheet\Style\NumberFormat')
@use('Carbon\CarbonInterval')
@use('App\Enums\TimeEntryAggregationType')
@inject('interval', 'App\Service\IntervalService')
<table>
<thead>
<tr>
<th style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group->description() }}
</th>
<th style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ $subGroup->description() }}
</th>
<th style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
Duration
</th>
<th style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
Duration (decimal)
</th>
<th style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
Amount ({{ Str::upper($currency) }})
</th>
</tr>
</thead>
<tbody>
@php
$counter = 1;
$totalDuration = 0;
$totalCost = 0;
@endphp
@foreach($data['grouped_data'] as $group1Entry)
@foreach($group1Entry['grouped_data'] as $group2Entry)
@php
$duration = CarbonInterval::seconds($group2Entry['seconds']);
@endphp
<tr>
@if($exportFormat === ExportFormat::ODS || $exportFormat === ExportFormat::CSV)
@if ($group === TimeEntryAggregationType::Billable)
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group1Entry['key'] ? 'Yes' : 'No' }}
</td>
@else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}
</td>
@endif
@if ($subGroup === TimeEntryAggregationType::Billable)
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group2Entry['key'] ? 'Yes' : 'No' }}
</td>
@else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
</td>
@endif
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $interval->format($duration) }}
</td>
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ round($duration->totalHours, 2) }}
</td>
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ round(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->toFloat(), 2) }}
</td>
@else
@if ($group === TimeEntryAggregationType::Billable)
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group1Entry['key'] ? 'Yes' : 'No' }}
</td>
@else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}
</td>
@endif
@if ($subGroup === TimeEntryAggregationType::Billable)
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group2Entry['key'] ? 'Yes' : 'No' }}
</td>
@else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
</td>
@endif
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}"
data-format="[hh]:mm:ss">
{{ $duration->totalDays }}
</td>
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}"
data-format="{{ NumberFormat::FORMAT_NUMBER_00 }}">
{{ $duration->totalHours }}
</td>
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}"
data-format="{{ NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1 }}">
{{ BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString() }}
</td>
@endif
</tr>
@php
++$counter;
$totalDuration += $group2Entry['seconds'];
$totalCost += $group2Entry['cost'];
@endphp
@endforeach
@endforeach
@php
$totalDurationInterval = CarbonInterval::seconds($totalDuration);
@endphp
<tr style="border: 1px solid black;">
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}"></td>
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
Total
</td>
@if($exportFormat === ExportFormat::ODS || $exportFormat === ExportFormat::CSV)
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ $interval->format($totalDurationInterval) }}
</td>
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ round($totalDurationInterval->totalHours, 2) }}
</td>
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ round(BigDecimal::ofUnscaledValue($totalCost, 2)->toFloat(), 2) }}
</td>
@else
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_FORMULA }}"
data-format="[hh]:mm:ss">
@if($counter > 1)
=SUM(C2:C{{ $counter }})
@else
=0
@endif
</td>
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_FORMULA }}"
data-format="{{ NumberFormat::FORMAT_NUMBER_00 }}">
@if($counter > 1)
=SUM(D2:D{{ $counter }})
@else
=0
@endif
</td>
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_FORMULA }}"
data-format="{{ NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1 }}">
@if($counter > 1)
=SUM(E2:E{{ $counter }})
@else
=0
@endif
</td>
@endif
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,127 @@
@use('Brick\Math\BigDecimal')
@use('PhpOffice\PhpSpreadsheet\Cell\DataType')
@use('Carbon\CarbonInterval')
@inject('interval', 'App\Service\IntervalService')
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Report</title>
<style>
body {
font-family: "Open Sans", sans-serif;
}
table {
font-size: 10px;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
</head>
<body>
<h1>Report</h1>
<div>
<span>{{ $start->format('Y-m-d') }} - {{ $end->format('Y-m-d') }}</span><br><br>
</div>
<div>
<span>Duration: {{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }}</span><br>
<span>Total cost: {{ round(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->toFloat(), 2) }}</span><br>
</div>
<div id="main-chart" style="width: 800px; height:400px;"></div>
@foreach($aggregatedData['grouped_data'] as $group1Entry)
<h2>{{ $group->description() }}: {{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}</h2>
<table>
<thead>
<tr>
<th>
{{ $subGroup->description() }}
</th>
<th>
Duration
</th>
<th>
Duration (decimal)
</th>
<th>
Amount ({{ Str::upper($currency) }})
</th>
</tr>
</thead>
<tbody>
@php
$counter = 1;
$totalDuration = 0;
$totalCost = 0;
@endphp
@foreach($group1Entry['grouped_data'] as $group2Entry)
@php
$duration = CarbonInterval::seconds($group2Entry['seconds']);
@endphp
<tr>
<td>
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
</td>
<td>
{{ $interval->format($duration) }}
</td>
<td>
{{ round($duration->totalHours, 2) }}
</td>
<td>
{{ round(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->toFloat(), 2) }}
</td>
</tr>
@php
$totalDuration += $group2Entry['seconds'];
$totalCost += $group2Entry['cost'];
@endphp
@endforeach
</tbody>
</table>
@endforeach
<script>
// Initialize the echarts instance based on the prepared dom
let element = document.getElementById('main-chart');
let myChart = echarts.init(element, null, {
renderer: 'svg'
});
// Specify the configuration items and data for the chart
let option = {
tooltip: {},
xAxis: {
data: ['{!! collect($dataHistoryChart['grouped_data'])->pluck('key')->implode("', '") !!}'],
rotate: 0
},
yAxis: {
minInterval: 1,
axisLabel: {
formatter: function (value, index) {
let totalSeconds = value;
let hours = Math.floor(totalSeconds / 3600);
totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60);
let seconds = totalSeconds % 60;
return hours + ':' + minutes + ':' + seconds;
}
}
},
series: [
{
name: 'time',
type: 'bar',
data: [{!! collect($dataHistoryChart['grouped_data'])->pluck('seconds')->implode(', ') !!}],
}
]
};
// Display the chart using the configuration items and data just specified.
myChart.setOption(option);
</script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<html>
<head>
<style>
body {
font-size: 12px;
margin: auto 20px;
}
</style>
</head>
<body>
<p>
<span class="pageNumber"></span> of <span class="totalPages"></span>
</p>
</body>
</html>

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Report</title>
<style>
body {
font-family: "Open Sans", sans-serif;
}
table {
font-size: 10px;
}
</style>
</head>
<body>
<h1>Detailed Report</h1>
<div>
<span>01.01.2020 - 01.01.2024</span>
</div>
<div>
<span>Duration: 20:10:10</span>
</div>
<div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Task</th>
<th>Project</th>
<th>Client</th>
<th>User</th>
<th>Duration</th>
<th>Billable</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
@foreach($timeEntries as $timeEntry)
<tr>
<td>{{ $timeEntry->description }}</td>
<td>{{ $timeEntry->task?->name ?? '-' }}</td>
<td>{{ $timeEntry->project?->name ?? '-' }}</td>
<td>{{ $timeEntry->client?->name ?? '-' }}</td>
<td>{{ $timeEntry->user->name }}</td>
<td>
00:00:01
{{ $timeEntry->start->format('Y-m-d H:i:s') }} - {{ $timeEntry->end->format('Y-m-d H:i:s') }}
</td>
<td>{{ $timeEntry->billable ? 'Yes' : 'no' }}</td>
<td>{{ $timeEntry->tagsRelation->implode('name', ', ') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<td class="header"> <td class="header">
<a href="{{ $url }}" style="display: inline-block;"> <a href="{{ $url }}" style="display: inline-block;">
@if(trim($slot) === 'solidtime') @if(trim($slot) === 'solidtime')
<img src="{{ asset('images/solidtime-logo.svg') }}" class="logo" alt="solidtime Logo"> <img src="{{ asset('images/solidtime-logo.png') }}" srcset="{{ asset('images/solidtime-logo.svg') }}" class="logo" alt="solidtime Logo">
@else @else
{{ $slot }} {{ $slot }}
@endif @endif

View File

@@ -87,7 +87,9 @@ Route::middleware([
// Time entry routes // Time entry routes
Route::name('time-entries.')->group(static function (): void { Route::name('time-entries.')->group(static function (): void {
Route::get('/organizations/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index'); Route::get('/organizations/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index');
Route::get('/organizations/{organization}/time-entries/export', [TimeEntryController::class, 'indexExport'])->name('index-export');
Route::get('/organizations/{organization}/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate'); Route::get('/organizations/{organization}/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate');
Route::get('/organizations/{organization}/time-entries/aggregate/export', [TimeEntryController::class, 'aggregateExport'])->name('aggregate-export');
Route::post('/organizations/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked'); Route::post('/organizations/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked'); Route::put('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::patch('/organizations/{organization}/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked'); Route::patch('/organizations/{organization}/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked');

View File

@@ -2,8 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Http\Controllers\Web\DashboardController; use App\Http\Controllers\Web\DashboardController;
use App\Http\Controllers\Web\HomeController; use App\Http\Controllers\Web\HomeController;
use Gotenberg\Gotenberg;
use Gotenberg\Stream;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
@@ -66,4 +70,22 @@ Route::middleware([
return Inertia::render('Import'); return Inertia::render('Import');
})->name('import'); })->name('import');
Route::get('/pdf-test', function () {
if (config('services.gotenberg.url') === null) {
throw new PdfRendererIsNotConfiguredException;
}
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php'));
$html = Blade::render($viewFile, ['aggregatedData' => []]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
$footerHtml = Blade::render($footerViewFile);
$request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf()
->pdfa('PDF/A-3b')
->paperSize('8.27', '11.7') // A4
->footer(Stream::string('footer', $footerHtml))
->html(Stream::string('body', $html));
return Gotenberg::send($request);
});
}); });

View File

@@ -63,6 +63,36 @@ class RegistrationTest extends TestCase
Event::assertNotDispatched(NewsletterRegistered::class); Event::assertNotDispatched(NewsletterRegistered::class);
} }
public function test_new_user_can_not_register_with_likely_invalid_domain(): void
{
// Act
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'peter.test@gmail',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
]);
// Assert
$response->assertInvalid(['email']);
}
public function test_new_user_can_register_with_uppercase_email(): void
{
// Act
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'PETER.test@gmail.com ',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
]);
// Assert
$response->assertValid(['email']);
}
public function test_new_users_can_consent_to_newsletter_during_registration(): void public function test_new_users_can_consent_to_newsletter_during_registration(): void
{ {
// Arrange // Arrange

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