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

View File

@@ -4,15 +4,15 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true
APP_URL=https://solidtime.test
AUDITING_ENABLED=true
SUPER_ADMINS=admin@example.com
# Logging
LOG_CHANNEL=single
LOG_DEPRECATIONS_CHANNEL=deprecation
LOG_LEVEL=debug
# Database
DB_CONNECTION=pgsql
DB_HOST=pgsql
DB_PORT=5432
DB_DATABASE=laravel
@@ -31,12 +31,7 @@ QUEUE_CONNECTION=sync
SESSION_DRIVER=database
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# Mail
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
@@ -54,7 +49,7 @@ PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
# Storage
# Filesystems
FILESYSTEM_DISK=s3
PUBLIC_FILESYSTEM_DISK=s3
S3_ACCESS_KEY_ID=sail
@@ -65,6 +60,9 @@ S3_URL=http://storage.solidtime.test/local
S3_ENDPOINT=http://storage.solidtime.test
S3_USE_PATH_STYLE_ENDPOINT=true
# Services
GOTENBERG_URL=http://gotenberg:3000
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
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-timeout 5s
--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:
- name: "Checkout code"
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/)
## 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
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.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
## Security
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.

View File

@@ -43,7 +43,7 @@ class CreateNewUser implements CreatesNewUsers
'email' => [
'required',
'string',
'email',
'email:rfc,strict',
'max:255',
UniqueEloquent::make(User::class, 'email', function (Builder $builder): 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;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryAggregationType: string
{
use LaravelEnumHelper;
case Day = 'day';
case Week = 'week';
case Month = 'month';
@@ -17,6 +21,16 @@ enum TimeEntryAggregationType: string
case Billable = 'billable';
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
{
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;
use App\Enums\ExportFormat;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
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\TimeEntryDestroyMultipleRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest;
@@ -21,15 +26,29 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
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\TimeEntryFilter;
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\Database\Eloquent\Builder;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
use Spatie\TemporaryDirectory\TemporaryDirectory;
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`.
* 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');
}
$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'));
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$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
*
@@ -160,7 +274,7 @@ class TimeEntryController extends Controller
*
* @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 */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
@@ -169,7 +283,146 @@ class TimeEntryController extends Controller
} else {
$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()
->whereBelongsTo($organization, 'organization');
@@ -184,27 +437,8 @@ class TimeEntryController extends Controller
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
$timeEntriesQuery = $filter->get();
$user = $this->user();
$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,
];
return $filter->get();
}
/**
@@ -333,6 +567,10 @@ class TimeEntryController extends Controller
$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']) {
throw new AuthorizationException;
}

View File

@@ -48,6 +48,8 @@ class HealthCheckController extends Controller
return response()
->json([
'ip_address' => $ipAddress,
'url' => $request->url(),
'path' => $request->path(),
'hostname' => $hostname,
'timestamp' => Carbon::now()->timestamp,
'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');
})->uuid(),
],
// Filter by tag IDs, tag IDs are AND combined
// Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [
'array',
'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');
})->uuid(),
],
// Filter by tag IDs, tag IDs are AND combined
// Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [
'array',
'min:1',

View File

@@ -7,11 +7,14 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\TagFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
/**
* @property string $id
@@ -19,6 +22,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $organization_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Collection<TimeEntry> $timeEntries
* @property-read Organization $organization
*
* @method static TagFactory factory()
@@ -30,6 +34,7 @@ class Tag extends Model implements AuditableContract
/** @use HasFactory<TagFactory> */
use HasFactory;
use HasJsonRelationships;
use HasUuids;
/**
@@ -48,4 +53,14 @@ class Tag extends Model implements AuditableContract
{
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 Database\Factories\TimeEntryFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -17,6 +18,8 @@ use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon;
use Korridor\LaravelComputedAttributes\ComputedAttributes;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
/**
* @property string $id
@@ -42,6 +45,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property-read Client|null $client
* @property string|null $task_id
* @property-read Task|null $task
* @property-read Collection<Tag> $tagsRelation
*
* @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory()
@@ -54,6 +58,7 @@ class TimeEntry extends Model implements AuditableContract
/** @use HasFactory<TimeEntryFactory> */
use HasFactory;
use HasJsonRelationships;
use HasUuids;
/**
@@ -197,4 +202,14 @@ class TimeEntry extends Model implements AuditableContract
{
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\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode;
use Laravel\Passport\HasApiTokens;
use Laravel\Passport\Token;
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 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
*/

View File

@@ -28,7 +28,6 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL;
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');
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 {

View File

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

View File

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

View File

@@ -144,6 +144,7 @@ class DeletionService
->get();
foreach ($members as $member) {
/** @var Member $member */
if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) {
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
}
@@ -154,10 +155,13 @@ class DeletionService
if ($member->role === Role::Owner->value) {
$this->deleteOrganization($member->organization, false, $user);
} 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
$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;
$placeholderUser = $user->replicate();
@@ -56,6 +56,8 @@ class MemberService
$member->save();
$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\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use Carbon\CarbonTimeZone;
use Illuminate\Database\Eloquent\Builder;
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{
* key: string|null,

View File

@@ -133,7 +133,11 @@ class TimeEntryFilter
if ($tagIds === null) {
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;
}

View File

@@ -8,9 +8,11 @@
"php": "8.3.*",
"ext-zip": "*",
"brick/money": "^0.9.0",
"datomatic/laravel-enum-helper": "^1.1",
"dedoc/scramble": "dev-main",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.2.0",
"gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^1.0",
"korridor/laravel-computed-attributes": "^3.1",
@@ -20,12 +22,15 @@
"laravel/octane": "^2.3",
"laravel/passport": "^12.0",
"laravel/tinker": "^2.8",
"league/csv": "^9.16.0",
"league/flysystem-aws-s3-v3": "^3.0",
"maatwebsite/excel": "^3.1",
"novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^11.0.11",
"owen-it/laravel-auditing": "^13.6",
"pxlrbt/filament-environment-indicator": "^2.0",
"spatie/temporary-directory": "^2.2",
"staudenmeir/eloquent-json-relations": "^1.1",
"stechstudio/filament-impersonate": "^3.8",
"tightenco/ziggy": "^2.1.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",
"This file is @generated automatically"
],
"content-hash": "84e9d436af2f46e57ecc42a117e94259",
"content-hash": "0ae21e521922b7431905a0764dfc0245",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -1408,6 +1408,113 @@
},
"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",
"version": "dev-main",
@@ -2093,6 +2200,67 @@
],
"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",
"version": "v3.2.115",
@@ -2733,6 +2901,87 @@
],
"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",
"version": "v1.1.3",
@@ -3282,6 +3531,73 @@
],
"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",
"version": "5.3.0",
@@ -3609,6 +3925,69 @@
},
"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",
"version": "3.4.0",
@@ -5435,6 +5814,271 @@
],
"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",
"version": "2.9.0",
@@ -6670,6 +7314,190 @@
},
"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",
"version": "1.9.3",
@@ -8407,6 +9235,109 @@
],
"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",
"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 Illuminate\Database\Seeder;
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
{
@@ -150,10 +156,35 @@ class DatabaseSeeder extends Seeder
User::factory()->withPersonalOrganization()->create([
'email' => 'admin@example.com',
]);
app(ClientRepository::class)->create(
null,
'desktop',
'solidtime://oauth/callback',
null,
false,
false,
false
);
}
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 TimeEntry)->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 Project)->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 Organization)->getTable())->delete();
DB::table((new User)->getTable())->delete();
}
}

View File

@@ -189,6 +189,13 @@ services:
entrypoint: /etc/minio/create_bucket.sh
extra_hosts:
- "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:
reverse-proxy:
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 expect(page.getByText('Add Project Member').first()).toBeVisible();
await page.getByRole('button', { name: 'Select a member' }).click();
await page.keyboard.press('Enter');
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'
);
await timeEntryRangeElement.click();
await page
.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 page.getByTestId('time_picker_input').first().fill('1');
await Promise.all([
page.waitForResponse(async (response) => {
return (
@@ -213,7 +206,7 @@ test('test that updating a the start of an existing time entry in the overview w
}),
page
.getByTestId('time_entry_range_end')
.getByTestId('time_picker_minute')
.getByTestId('time_picker_input')
.press('Enter'),
]);
});

View File

@@ -6,10 +6,12 @@ use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembe
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
@@ -33,6 +35,8 @@ return [
ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed',
ExportException::KEY => 'Export failed, please try again later or contact support',
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.',
];

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

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

View File

@@ -1,13 +1,12 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
import { useMembersStore } from '@/utils/useMembers';
import { UserIcon, XMarkIcon } from '@heroicons/vue/24/solid';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
import { useFocus } from '@vueuse/core';
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 { members } = storeToRefs(membersStore);
@@ -31,13 +30,9 @@ const searchInput = ref<HTMLInputElement | null>(null);
const searchValue = ref('');
function isMemberSelected(id: string) {
return model.value === id;
}
useFocus(searchInput, { initialValue: true });
const filteredMembers = computed(() => {
const filteredMembers = computed<Member[]>(() => {
return members.value.filter((member) => {
return (
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 highlightedItem = computed(() => {
return members.value.find(
(member) => member.id === highlightedItemId.value
);
});
const currentValue = computed(() => {
if (model.value) {
@@ -136,70 +68,27 @@ const currentValue = computed(() => {
}
return searchValue.value;
});
const hasMemberSelected = computed(() => {
return model.value !== '';
});
const showMembersDropdown = ref(true);
</script>
<template>
<Dropdown
align="bottom-start"
width="300"
v-model="showMembersDropdown"
:closeOnContentClick="true">
<template #trigger>
<div class="flex relative">
<div
ref="reference"
class="absolute h-full items-center px-3 w-full flex justify-between">
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
<button
v-if="hasMemberSelected"
@click="model = ''"
class="focus:text-accent-200 focus:bg-card-background text-muted">
<XMarkIcon class="relative z-10 w-4"></XMarkIcon>
</button>
<SelectDropdown
v-model="model"
:items="filteredMembers"
:get-key-from-item="(member) => member.id"
:get-name-for-item="(member) => member.name">
<template v-slot:trigger>
<Badge
tag="button"
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
<div v-if="currentValue" class="flex-1 truncate">
{{ currentValue }}
</div>
<TextInput
:value="currentValue"
:disabled="disabled"
@input="updateSearchValue"
data-testid="member_dropdown_search"
@keydown.enter.prevent="updateMember(highlightedItemId)"
@keydown.up.prevent="moveHighlightUp"
class="relative w-full pl-10"
@keydown.down.prevent="moveHighlightDown"
placeholder="Search for a member..."
ref="searchInput" />
</div>
<div class="flex-1" v-else>Select a member...</div>
<ChevronDownIcon class="w-4 text-muted"></ChevronDownIcon>
</Badge>
</template>
<template #content>
<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>
</SelectDropdown>
</template>
<style scoped></style>

View File

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

View File

@@ -39,6 +39,7 @@ const { clients } = storeToRefs(useClientsStore());
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;`;
});
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
</script>
<template>
@@ -47,6 +48,7 @@ const gridTemplate = computed(() => {
:createClient
:currency="getOrganizationCurrencyString()"
:clients="clients"
:enableEstimatedTime="isAllowedToPerformPremiumAction"
v-model:show="showCreateProjectModal"></ProjectCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle">
@@ -63,9 +65,19 @@ const gridTemplate = computed(() => {
v-if="projects.length === 0">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-white font-semibold">No projects found</h3>
<p class="pb-5" v-if="canCreateProjects()">
Create your first project now!
<h3 class="text-white font-semibold">
{{
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>
<SecondaryButton
v-if="canCreateProjects()"

View File

@@ -89,6 +89,7 @@ useFocus(projectNameInput, { initialValue: true });
<div class="col-span-3 sm:col-span-1 flex-1">
<InputLabel
for="billable_rate"
class="mb-2"
value="Billable Rate"></InputLabel>
<BillableRateInput
@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 { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
const page = usePage<{
auth: {
@@ -82,7 +84,11 @@ const isRunningInDifferentOrganization = computed(() => {
async function createProject(
project: CreateProjectBody
): 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) {
return await useClientsStore().createClient(client);
@@ -111,6 +117,8 @@ const { tags } = storeToRefs(useTagsStore());
<TimeTrackerControls
:createProject
:enableEstimatedTime="isAllowedToPerformPremiumAction()"
:canCreateProject="canCreateProjects()"
:createClient
:clients
:tags

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import {
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { onMounted, ref } from 'vue';
import {
formatHumanReadableDuration,
@@ -21,7 +22,7 @@ import {
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { storeToRefs } from 'pinia';
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 ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.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 { getOrganizationCurrencyString } from '@/utils/money';
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 { useTagsStore } from '@/utils/useTags';
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 TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
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>(
'reporting-start-date',
@@ -66,7 +75,7 @@ const { aggregatedGraphTimeEntries, aggregatedTableTimeEntries } =
const { groupByOptions } = reportingStore;
function getFilterAttributes() {
function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
let params: AggregatedTimeEntriesQueryParams = {
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
end: getLocalizedDayJs(endDate.value).endOf('day').utc().format(),
@@ -94,16 +103,12 @@ function getFilterAttributes() {
}
function updateGraphReporting() {
const diffInDays = getDayJsInstance()(endDate.value).diff(
getDayJsInstance()(startDate.value),
'd'
);
const params = getFilterAttributes();
if (getCurrentRole() === 'employee') {
params.member_id = getCurrentMembershipId();
}
params.fill_gaps_in_time_groups = 'true';
params.group = getOptimalGroupingOption(diffInDays);
params.group = getOptimalGroupingOption(startDate.value, endDate.value);
useReportingStore().fetchGraphReporting(params);
}
@@ -130,10 +135,18 @@ function updateReporting() {
updateTableReporting();
}
function getOptimalGroupingOption(diff: number): 'day' | 'week' | 'month' {
if (diff <= 31) {
function getOptimalGroupingOption(
startDate: string,
endDate: string
): 'day' | 'week' | 'month' {
const diffInDays = getDayJsInstance()(endDate).diff(
getDayJsInstance()(startDate),
'd'
);
if (diffInDays <= 31) {
return 'day';
} else if (diff <= 200) {
} else if (diffInDays <= 200) {
return 'week';
} else {
return 'month';
@@ -149,6 +162,33 @@ const { tags } = storeToRefs(useTagsStore());
async function createTag(tag: string) {
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>
<template>
@@ -170,8 +210,10 @@ async function createTag(tag: string) {
>
</TabBar>
</div>
<ReportingExportButton
:download="downloadExport"></ReportingExportButton>
</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
class="sm:flex space-y-4 sm:space-y-0 justify-between">
<div

View File

@@ -64,7 +64,12 @@ import {
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
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>(
'reporting-start-date',
@@ -118,7 +123,9 @@ function getFilterAttributes() {
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
const { setActiveState, startLiveTimer } = currentTimeEntryStore;
const { createTimeEntry, updateTimeEntry } = useTimeEntriesStore();
const { handleApiRequestNotifications } = useNotificationsStore();
const { createTimeEntry, updateTimeEntry, updateTimeEntries } =
useTimeEntriesStore();
const { tags } = storeToRefs(useTagsStore());
@@ -212,6 +219,26 @@ async function clearSelectionAndState() {
selectedTimeEntries.value = [];
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>
<template>
@@ -234,6 +261,8 @@ async function clearSelectionAndState() {
</TabBarItem>
</TabBar>
</div>
<ReportingExportButton
:download="downloadExport"></ReportingExportButton>
</MainContainer>
<div class="py-2.5 w-full border-b border-default-background-separator">
<MainContainer
@@ -338,18 +367,34 @@ async function clearSelectionAndState() {
</div>
<TimeEntryMassActionRow
:selected-time-entries="selectedTimeEntries"
:canCreateProject="canCreateProjects()"
:enableEstimatedTime="isAllowedToPerformPremiumAction()"
@submit="clearSelectionAndState"
:delete-selected="deleteSelected"
@select-all="selectedTimeEntries = [...timeEntries]"
@unselect-all="selectedTimeEntries = []"
:all-selected="
selectedTimeEntries.length === timeEntries.length
"></TimeEntryMassActionRow>
:all-selected="selectedTimeEntries.length === timeEntries.length"
: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>
<div class="w-full relative">
<div v-for="entry in timeEntries" :key="entry.id">
<TimeEntryRow
:selected="selectedTimeEntries.includes(entry)"
@selected="selectedTimeEntries.push(entry)"
:canCreateProject="canCreateProjects()"
@unselected="
selectedTimeEntries = selectedTimeEntries.filter(
(item) => item.id !== entry.id
@@ -357,6 +402,7 @@ async function clearSelectionAndState() {
"
:createClient
:createProject
:enableEstimatedTime="isAllowedToPerformPremiumAction()"
:projects="projects"
:tasks="tasks"
:tags="tags"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -105,7 +105,7 @@ const ProjectMemberResource = z
.passthrough();
const ProjectMemberStoreRequest = z
.object({
member_id: z.string().uuid(),
member_id: z.string(),
billable_rate: z.union([z.number(), z.null()]).optional(),
})
.passthrough();
@@ -172,14 +172,14 @@ const TimeEntryResource = z
.passthrough();
const TimeEntryStoreRequest = z
.object({
member_id: z.string().uuid(),
member_id: z.string(),
project_id: z.union([z.string(), z.null()]).optional(),
task_id: z.union([z.string(), z.null()]).optional(),
start: z.string(),
end: z.union([z.string(), z.null()]).optional(),
billable: z.boolean(),
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();
const TimeEntryUpdateMultipleRequest = z
@@ -187,12 +187,12 @@ const TimeEntryUpdateMultipleRequest = z
ids: z.array(z.string().uuid()),
changes: z
.object({
member_id: z.string().uuid(),
member_id: z.string(),
project_id: z.union([z.string(), z.null()]),
task_id: z.union([z.string(), z.null()]),
billable: z.boolean(),
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()
.passthrough(),
@@ -200,14 +200,14 @@ const TimeEntryUpdateMultipleRequest = z
.passthrough();
const TimeEntryUpdateRequest = z
.object({
member_id: z.string().uuid(),
member_id: z.string(),
project_id: z.union([z.string(), z.null()]),
task_id: z.union([z.string(), z.null()]),
start: z.string(),
end: z.union([z.string(), z.null()]),
billable: z.boolean(),
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()
.passthrough();
@@ -370,7 +370,7 @@ const endpoints = makeApi([
{
name: 'page',
type: 'Query',
schema: z.number().int().gte(1).optional(),
schema: z.number().int().gte(1).lte(2147483647).optional(),
},
{
name: 'archived',
@@ -1317,7 +1317,7 @@ const endpoints = makeApi([
{
name: 'page',
type: 'Query',
schema: z.number().int().gte(1).optional(),
schema: z.number().int().gte(1).lte(2147483647).optional(),
},
{
name: 'archived',
@@ -1889,7 +1889,7 @@ const endpoints = makeApi([
{
name: 'project_id',
type: 'Query',
schema: z.string().uuid().optional(),
schema: z.string().optional(),
},
{
name: 'done',
@@ -2118,7 +2118,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
{
name: 'member_id',
type: 'Query',
schema: z.string().uuid().optional(),
schema: z.string().optional(),
},
{
name: 'start',
@@ -2148,7 +2148,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
{
name: 'offset',
type: 'Query',
schema: z.number().int().gte(0).optional(),
schema: z.number().int().gte(0).lte(2147483647).optional(),
},
{
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',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'client_ids',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'project_ids',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_ids',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'task_ids',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
schema: z.array(z.string()).min(1).optional(),
},
{
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',
type: 'Query',
schema: z.string().uuid().optional(),
schema: z.string().optional(),
},
{
name: 'user_id',
type: 'Query',
schema: z.string().uuid().optional(),
schema: z.string().optional(),
},
{
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',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'project_ids',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'client_ids',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_ids',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'task_ids',
type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(),
schema: z.array(z.string()).min(1).optional(),
},
],
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',
path: '/v1/users/me',

View File

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

View File

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

View File

@@ -2,16 +2,20 @@
import type { HtmlButtonType } from '@/types/dom';
import { twMerge } from 'tailwind-merge';
import { type Component } from 'vue';
import LoadingSpinner from '../LoadingSpinner.vue';
const props = withDefaults(
defineProps<{
type: HtmlButtonType;
icon?: Component;
size: 'small' | 'base';
loading: boolean;
class?: string;
}>(),
{
type: 'button',
size: 'base',
loading: false,
}
);
@@ -24,20 +28,23 @@ const sizeClasses = {
<template>
<button
:type="type"
:disabled="loading"
:class="
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',
sizeClasses[props.size]
sizeClasses[props.size],
props.class
)
">
<span
:class="
twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')
">
<LoadingSpinner v-if="loading"></LoadingSpinner>
<component
v-if="props.icon"
v-if="props.icon && !loading"
: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>
<slot />
</span>

View File

@@ -82,7 +82,7 @@ const inputValue = ref(formatValue(model.value));
type="text"
:name="name"
placeholder="Billable Rate"
class="mt-2 block w-full"
class="block w-full"
autocomplete="teamMemberRate" />
<div
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"
:class="
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
)
"
@@ -68,6 +68,7 @@ const emit = defineEmits(['changed']);
<style scoped>
input::-webkit-calendar-picker-indicator {
filter: invert(1);
opacity: 0.2;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import {
flip,
limitShift,
@@ -10,6 +10,8 @@ import {
} from '@floating-ui/vue';
import { offset } 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(
defineProps<{
@@ -24,17 +26,28 @@ const props = withDefaults(
const emit = defineEmits(['open', 'submit']);
const open = defineModel({ default: false });
const id = useId();
const closeOnEscape = (e: KeyboardEvent) => {
if (open.value && e.key === 'Escape') {
open.value = false;
}
if (open.value && e.key === 'Enter') {
emit('submit');
if (props.closeOnContentClick) open.value = false;
if (isLastLayer(id)) {
if (open.value && e.key === 'Escape') {
open.value = false;
}
if (open.value && e.key === 'Enter') {
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));
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">
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 { onKeyStroke } from '@vueuse/core';
import { type Placement } from '@floating-ui/vue';
import { twMerge } from 'tailwind-merge';
const model = defineModel<string | null>({
default: null,
});
const open = defineModel('open', {
default: false,
});
const props = withDefaults(
defineProps<{
items: T[];
getKeyFromItem: (item: T) => string | null;
getNameForItem: (item: T) => string;
align?: Placement;
class?: string;
}>(),
{
align: 'bottom-start',
}
);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const dropdownViewport = ref<HTMLDivElement | null>(null);
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, () => {
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]);
}
});
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']);
function setItem(newValue: string | null) {
@@ -84,7 +112,6 @@ function moveHighlightDown() {
}
}
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return props.items.find(
(item) => props.getKeyFromItem(item) === highlightedItemId.value
@@ -114,7 +141,15 @@ onKeyStroke('Enter', (e) => {
watch(open, () => {
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>
@@ -125,18 +160,28 @@ watch(open, () => {
<slot name="trigger"> </slot>
</template>
<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
v-for="item in filteredItems"
:key="props.getKeyFromItem(item) ?? 'none'"
role="option"
:data-select-id="props.getKeyFromItem(item)"
:value="props.getKeyFromItem(item)"
:class="{
'bg-card-background-active':
props.getKeyFromItem(item) === highlightedItemId,
}"
:data-item-id="props.getKeyFromItem(item)">
<SelectDropdownItem
@mouseenter="
highlightedItemId = props.getKeyFromItem(item)
"
:highlighted="
props.getKeyFromItem(item) === highlightedItemId
"
:selected="props.getKeyFromItem(item) === model"
@click="setItem(props.getKeyFromItem(item))"
:name="props.getNameForItem(item)"></SelectDropdownItem>

View File

@@ -4,20 +4,24 @@ import { twMerge } from 'tailwind-merge';
const props = defineProps<{
name: string;
selected: boolean;
highlighted: boolean;
}>();
</script>
<template>
<div
:class="
twMerge(
'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 ',
props.selected
? 'bg-accent-300/20'
: 'hover:bg-card-background-active'
)
">
<span>{{ name }}</span>
<div class="px-1">
<div
:class="
twMerge(
'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 ',
props.highlighted && 'bg-card-background-active',
props.selected
? 'bg-accent-300/20'
: 'hover:bg-card-background-active'
)
">
<span>{{ name }}</span>
</div>
</div>
</template>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { twMerge } from 'tailwind-merge';
defineProps<{
const props = defineProps<{
name?: string;
class?: string;
}>();
const input = ref<HTMLInputElement | null>(null);
@@ -20,7 +22,12 @@ const model = defineModel();
<template>
<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"
:name="name" />
</template>

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { twMerge } from 'tailwind-merge';
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
const model = defineModel<string | null>({
@@ -23,98 +24,156 @@ const props = withDefaults(
}
);
const hours = ref(
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) {
function updateTime(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
if (!isNaN(parseInt(newValue))) {
model.value = getDayJsInstance()(model.value)
.set('minutes', Math.min(parseInt(newValue), 59))
.format();
const newValue = target.value.trim();
if (newValue.split(':').length === 2) {
const [hours, minutes] = newValue.split(':');
if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) {
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
? getLocalizedDayJs(model.value).format('mm')
: null;
// check if input is only numbers
else if (/^\d+$/.test(newValue)) {
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) {
const target = event.target as HTMLInputElement;
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;
}
watch(model, (value) => {
inputValue.value = value ? getLocalizedDayJs(value).format('HH:mm') : null;
});
const hoursInput = ref<HTMLInputElement | null>(null);
const minutesInput = ref<HTMLInputElement | null>(null);
const timeInput = ref<HTMLInputElement | null>(null);
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>
<template>
<div class="flex items-center justify-center text-white">
<div
:class="
twMerge(
'border bg-input-background rounded-md border-input-border overflow-hidden',
props.size === 'large' ? 'py-1.5 px-2' : ''
)
">
<input
v-model="hours"
ref="hoursInput"
@input="updateHours"
@keydown.enter="emit('changed')"
@focus="($event.target as HTMLInputElement).select()"
data-testid="time_picker_hour"
type="text"
:class="
twMerge(
'border-none bg-transparent px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active',
props.size === 'large' ? 'text-base' : 'text-sm'
)
" />
<span>:</span>
<input
v-model="minutes"
ref="minutesInput"
@keydown.enter="emit('changed')"
@input="updateMinutes"
@focus="($event.target as HTMLInputElement).select()"
data-testid="time_picker_minute"
type="text"
:class="
twMerge(
'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 class="flex min-w-0 items-center justify-center text-white">
<SelectDropdown
:class="twMerge('mine-w-0 w-24', size === 'large' && 'w-28')"
v-model="closestValue"
v-model:open="open"
:get-key-from-item="(item: TimeOption) => item.timestamp"
:get-name-for-item="(item: TimeOption) => item.name"
:items="getStartOptions">
<template #trigger>
<TextInput
v-model="inputValue"
ref="timeInput"
:class="
twMerge(
'text-center w-24 px-3 py-2',
size === 'large' && 'w-28'
)
"
@blur="updateTime"
@keydown.enter="
updateTime($event);
open = false;
"
@keydown.tab="open = false"
@focus="($event.target as HTMLInputElement).select()"
@mouseup="($event.target as HTMLInputElement).select()"
@click="($event.target as HTMLInputElement).select()"
@pointerup="($event.target as HTMLInputElement).select()"
@focusin="open = true"
data-testid="time_picker_input"
type="text" />
</template>
</SelectDropdown>
</div>
</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">
<div class="px-2">
<div class="font-bold text-white text-sm pb-2">Start</div>
<div class="space-y-1">
<div class="space-y-2">
<TimePicker
data-testid="time_entry_range_start"
:focus
@changed="updateTimeEntry"
v-model="tempStart"></TimePicker>
<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"
v-model="tempStart"></DatePicker>
</div>
</div>
<div class="px-2">
<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
data-testid="time_entry_range_end"
@changed="updateTimeEntry"
v-model="tempEnd"></TimePicker>
<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"
v-model="tempEnd"></DatePicker>
</div>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch } from 'vue';
import { useId } from 'radix-vue';
import { isLastLayer, layers } from '@/packages/ui/src/utils/dismissableLayer';
const props = defineProps({
show: {
@@ -34,20 +36,35 @@ const close = () => {
emit('close');
}
};
const id = useId();
const closeOnEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.show) {
close();
if (isLastLayer(id)) {
if (e.key === 'Escape' && props.show) {
close();
}
}
};
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onMounted(() => {
document.addEventListener('keydown', closeOnEscape);
});
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape);
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(() => {
return {
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 ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
import type { Client } from '@/packages/api/src';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
const show = defineModel('show', { default: false });
const saving = ref(false);
@@ -29,6 +28,7 @@ const props = defineProps<{
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string;
enableEstimatedTime: boolean;
}>();
const activeClients = computed(() => {
@@ -138,7 +138,7 @@ const currentClientName = computed(() => {
</div>
<div>
<EstimatedTimeSection
v-if="isAllowedToPerformPremiumAction()"
v-if="enableEstimatedTime"
@submit="submit()"
v-model="project.estimated_time"></EstimatedTimeSection>
</div>

View File

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

View File

@@ -39,6 +39,8 @@ const props = defineProps<{
deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
currency: string;
selectedTimeEntries: TimeEntry[];
enableEstimatedTime: boolean;
canCreateProject: boolean;
}>();
const emit = defineEmits<{
selected: [TimeEntry[]];
@@ -120,11 +122,13 @@ function onSelectChange(event: Event) {
:clients
:createProject
:createClient
:canCreateProject
:projects="projects"
:tasks="tasks"
:showBadgeBorder="false"
@changed="updateProjectAndTask"
:project="timeEntry.project_id"
:enableEstimatedTime
:currency="currency"
:task="
timeEntry.task_id
@@ -174,10 +178,13 @@ function onSelectChange(event: Event) {
class="w-full border-t border-default-background-separator bg-black/15">
<TimeEntryRow
:projects="projects"
:enableEstimatedTime
:canCreateProject
:tasks="tasks"
:selected="
!!selectedTimeEntries.find(
(filterEntry) => filterEntry.id === subEntry.id
(filterEntry: TimeEntry) =>
filterEntry.id === subEntry.id
)
"
@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>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string;
enableEstimatedTime: boolean;
canCreateProject: boolean;
}>();
const groupedTimeEntries = computed(() => {
@@ -113,6 +115,7 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
end: null,
billable: entry.billable,
description: entry.description,
tags: [...entry.tags],
});
}
function sumDuration(timeEntries: TimeEntry[]) {
@@ -152,16 +155,18 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
@select-all="selectAllTimeEntries(value)"
@unselect-all="unselectAllTimeEntries(value)"
:checked="
value.every((timeEntry) =>
value.every((timeEntry: TimeEntry) =>
selectedTimeEntries.includes(timeEntry)
)
"></TimeEntryRowHeading>
<template v-for="entry in value" :key="entry.id">
<TimeEntryAggregateRow
:createProject
:canCreateProject
:enableEstimatedTime
:selected-time-entries="selectedTimeEntries"
@selected="
(timeEntries) => {
(timeEntries: TimeEntry[]) => {
selectedTimeEntries = [
...selectedTimeEntries,
...timeEntries,
@@ -169,11 +174,12 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
}
"
@unselected="
(timeEntriesToUnselect) => {
(timeEntriesToUnselect: TimeEntry[]) => {
selectedTimeEntries = selectedTimeEntries.filter(
(item) =>
(item: TimeEntry) =>
!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>
<TimeEntryRow
:createClient
:enableEstimatedTime
:canCreateProject
:createProject
:projects="projects"
:selected="
!!selectedTimeEntries.find(
(filterEntry) => filterEntry.id === entry.id
(filterEntry: TimeEntry) => filterEntry.id === entry.id
)
"
@selected="selectedTimeEntries.push(entry)"
@unselected="
selectedTimeEntries = selectedTimeEntries.filter(
(item) => item.id !== entry.id
(item: TimeEntry) => item.id !== entry.id
)
"
:tasks="tasks"

View File

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

View File

@@ -1,66 +1,50 @@
<script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import TextInput from '../Input/TextInput.vue';
import SecondaryButton from '../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 PrimaryButton from '../Buttons/PrimaryButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects';
import { useTagsStore } from '@/utils/useTags';
import InputLabel from '../Input/InputLabel.vue';
import {
type CreateClientBody,
type CreateProjectBody,
type Project,
type Client,
api,
type TimeEntry,
type UpdateMultipleTimeEntriesChangeset,
} from '@/packages/api/src';
import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money';
import { Badge, Checkbox } from '@/packages/ui/src';
import SelectDropdown from '../../../packages/ui/src/Input/SelectDropdown.vue';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import SelectDropdown from '../Input/SelectDropdown.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
const clientStore = useClientsStore();
const { clients } = storeToRefs(clientStore);
import type { Tag, Task } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const saving = ref(false);
async function createProject(
project: CreateProjectBody
): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
const props = defineProps<{
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<{
submit: [];
}>();
async function createClient(
body: CreateClientBody
): Promise<Client | undefined> {
return await useClientsStore().createClient(body);
}
const descriptionInput = ref<HTMLInputElement | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
watch(show, (value) => {
if (value) {
nextTick(() => {
@@ -75,11 +59,6 @@ const projectId = ref<string | null>(null);
const billable = ref<boolean | undefined>(undefined);
const selectedTags = ref<string[]>([]);
const { tags } = storeToRefs(useTagsStore());
async function createTag(tag: string) {
return await useTagsStore().createTag(tag);
}
const timeEntryBillable = computed({
get: () => {
if (billable.value === undefined) {
@@ -99,71 +78,48 @@ const timeEntryBillable = computed({
});
async function submit() {
const organizationId = getCurrentOrganizationId();
saving.value = true;
if (organizationId) {
const timeEntryUpdatesBody = {} as UpdateMultipleTimeEntriesChangeset;
if (description.value && description.value !== '') {
timeEntryUpdatesBody.description = description.value;
const timeEntryUpdatesBody = {} as UpdateMultipleTimeEntriesChangeset;
if (description.value && 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) {
if (projectId.value === '') {
// "No Project" is selected
timeEntryUpdatesBody.project_id = null;
} else {
timeEntryUpdatesBody.project_id = projectId.value;
}
timeEntryUpdatesBody.task_id = null;
if (taskId.value !== undefined) {
timeEntryUpdatesBody.task_id = taskId.value;
}
timeEntryUpdatesBody.task_id = null;
if (taskId.value !== undefined) {
timeEntryUpdatesBody.task_id = taskId.value;
}
}
if (billable.value !== undefined) {
timeEntryUpdatesBody.billable = billable.value;
}
if (selectedTags.value.length > 0) {
timeEntryUpdatesBody.tags = selectedTags.value;
}
if (removeAllTags.value) {
timeEntryUpdatesBody.tags = [];
}
if (billable.value !== undefined) {
timeEntryUpdatesBody.billable = billable.value;
}
if (selectedTags.value.length > 0) {
timeEntryUpdatesBody.tags = selectedTags.value;
}
if (removeAllTags.value) {
timeEntryUpdatesBody.tags = [];
}
try {
await handleApiRequestNotifications(
() =>
api.updateMultipleTimeEntries(
{
ids: props.timeEntries.map(
(timeEntry) => timeEntry.id
),
changes: {
...timeEntryUpdatesBody,
},
},
{
params: {
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;
}
try {
await props.updateTimeEntries({ ...timeEntryUpdatesBody });
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);
@@ -172,6 +128,7 @@ watch(removeAllTags, () => {
selectedTags.value = [];
}
});
type SelectOption = { label: string; value: string };
</script>
<template>
@@ -200,11 +157,13 @@ watch(removeAllTags, () => {
:clients
:createProject
:createClient
:currency="getOrganizationCurrencyString()"
:currency="currency"
:canCreateProject
class="mt-1"
empty-placeholder="Select project..."
allow-reset
size="xlarge"
:enableEstimatedTime
:projects="projects"
:tasks="tasks"
v-model:project="projectId"
@@ -242,8 +201,12 @@ watch(removeAllTags, () => {
<div class="flex">
<SelectDropdown
v-model="timeEntryBillable"
:get-key-from-item="(item) => item.value"
:get-name-for-item="(item) => item.label"
:get-key-from-item="
(item: SelectOption) => item.value
"
:get-name-for-item="
(item: SelectOption) => item.label
"
:items="[
{
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 { formatDate, formatStartEnd } from '@/packages/ui/src/utils/time';
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
import { twMerge } from 'tailwind-merge';
defineProps<{
start: string;
@@ -27,11 +28,15 @@ const open = ref(false);
<template #trigger>
<button
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="{
'text-sm py-2 font-medium': !showDate,
'text-xs py-1.5 font-semibold': showDate,
}">
:class="
twMerge(
'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',
showDate
? 'text-xs py-1.5 font-semibold'
: 'text-sm py-2 font-medium',
open && 'border-card-border bg-card-background'
)
">
{{ formatStartEnd(start, end) }}
<span v-if="showDate" class="text-text-tertiary font-medium"
>{{ formatDate(start) }}

View File

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

View File

@@ -71,8 +71,9 @@ function selectInput(event: Event) {
<template #trigger>
<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"
@keydown.tab="open = false"
@blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="updateTimerAndStartLiveTimerUpdate"
v-model="currentTime" />

View File

@@ -33,6 +33,8 @@ const props = defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
isActive: boolean;
currency: string;
enableEstimatedTime: boolean;
canCreateProject: boolean;
}>();
const emit = defineEmits<{
@@ -92,7 +94,7 @@ function updateTimeEntryDescription() {
class="flex items-center relative @container"
data-testid="dashboard_timer">
<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">
<input
placeholder="What are you working on?"
@@ -101,26 +103,28 @@ function updateTimeEntryDescription() {
v-model="tempDescription"
@keydown.enter="startTimerIfNotActive"
@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" />
</div>
<div class="flex items-center justify-between pl-2 shrink min-w-0">
<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
:createClient
:canCreateProject
:clients
:createProject
:currency="currency"
:projects="projects"
:tasks="tasks"
@changed="updateProject"
:enableEstimatedTime="enableEstimatedTime"
v-model:project="currentTimeEntry.project_id"
v-model:task="
currentTimeEntry.task_id
"></TimeTrackerProjectTaskDropdown>
</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
@changed="$emit('updateTimeEntry')"
:createTag
@@ -149,7 +153,7 @@ function updateTimeEntryDescription() {
</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
:active="isActive"
@changed="onToggleButtonPress"

View File

@@ -19,6 +19,7 @@ import {
XMarkIcon,
} from '@heroicons/vue/16/solid';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import { twMerge } from 'tailwind-merge';
const task = defineModel<string | null>('task', {
default: null,
@@ -63,6 +64,9 @@ const props = withDefaults(
currency: string;
emptyPlaceholder: string;
allowReset: boolean;
enableEstimatedTime: boolean;
canCreateProject: boolean;
class?: string;
}>(),
{
showBadgeBorder: true,
@@ -531,7 +535,7 @@ const showCreateProject = ref(false);
</script>
<template>
<div v-if="projects.length === 0">
<div v-if="projects.length === 0 && canCreateProject">
<Badge
@click="showCreateProject = true"
size="large"
@@ -550,7 +554,12 @@ const showCreateProject = ref(false);
:border="showBadgeBorder"
tag="button"
: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">
<span class="whitespace-nowrap text-xs lg:text-sm">
{{ selectedProjectName }}
@@ -688,7 +697,9 @@ const showCreateProject = ref(false);
</template>
</template>
</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
@click="
open = false;
@@ -704,6 +715,7 @@ const showCreateProject = ref(false);
</Dropdown>
<ProjectCreateModal
:createClient
:enableEstimatedTime="enableEstimatedTime"
:currency="currency"
:clients="clients"
:createProject

View File

@@ -25,6 +25,9 @@ import SelectDropdown from './Input/SelectDropdown.vue';
import Badge from './Badge.vue';
import Checkbox from './Input/Checkbox.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 {
money,
@@ -46,4 +49,7 @@ export {
Badge,
Checkbox,
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() {
refreshStores();
useTimeEntriesStore().fetchTimeEntries();
}
export function refreshStores() {
@@ -17,6 +16,7 @@ export function refreshStores() {
useTasksStore().fetchTasks();
useTagsStore().fetchTags();
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
useTimeEntriesStore().patchTimeEntries();
if (canViewMembers()) {
useMembersStore().fetchMembers();
}

View File

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

View File

@@ -13,6 +13,7 @@ import {
} from '@/packages/api/src';
import dayjs from 'dayjs';
import { useNotificationsStore } from '@/utils/notification';
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
export const useTimeEntriesStore = defineStore('timeEntries', () => {
const timeEntries = ref<TimeEntry[]>(reactive([]));
@@ -20,6 +21,39 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
const allTimeEntriesLoaded = ref(false);
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(
queryParams: TimeEntriesQueryParams = {
only_full_dates: 'true',
@@ -83,7 +117,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
async function updateTimeEntries(
ids: string[],
changes: Partial<TimeEntry>
changes: UpdateMultipleTimeEntriesChangeset
) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
@@ -199,5 +233,6 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
allTimeEntriesLoaded,
updateTimeEntries,
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">
<a href="{{ $url }}" style="display: inline-block;">
@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
{{ $slot }}
@endif

View File

@@ -87,7 +87,9 @@ Route::middleware([
// Time entry routes
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/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/export', [TimeEntryController::class, 'aggregateExport'])->name('aggregate-export');
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::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);
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Http\Controllers\Web\DashboardController;
use App\Http\Controllers\Web\HomeController;
use Gotenberg\Gotenberg;
use Gotenberg\Stream;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Laravel\Jetstream\Jetstream;
@@ -66,4 +70,22 @@ Route::middleware([
return Inertia::render('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);
}
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
{
// Arrange

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