mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
40 Commits
v0.3.0
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e3ac45ce0 | ||
|
|
2cf9b3aa8f | ||
|
|
64b41e3018 | ||
|
|
31014c1e29 | ||
|
|
d880717749 | ||
|
|
df0f3b2680 | ||
|
|
4b0cb2e282 | ||
|
|
d5699da234 | ||
|
|
96f06bae1d | ||
|
|
e1243178fe | ||
|
|
cfbc98705a | ||
|
|
f0d6b234e5 | ||
|
|
4b622afcfc | ||
|
|
45daeead61 | ||
|
|
95c1bcd4cb | ||
|
|
3b3f593080 | ||
|
|
4224fdd57e | ||
|
|
f4cfeaa718 | ||
|
|
04fcc1e3ae | ||
|
|
f145e821a8 | ||
|
|
eaaa83406d | ||
|
|
9a60e2b911 | ||
|
|
5a1e05374c | ||
|
|
ab4dbd64df | ||
|
|
8712cfb9dc | ||
|
|
7c1fe35754 | ||
|
|
b0bcc4f330 | ||
|
|
5593d141ea | ||
|
|
d080b07e60 | ||
|
|
64535ceea6 | ||
|
|
e54df74d5d | ||
|
|
27b40d863e | ||
|
|
b41d20839e | ||
|
|
7acadda6d8 | ||
|
|
cd7573dcf1 | ||
|
|
eb4debe481 | ||
|
|
fd77e1e901 | ||
|
|
401cd4be0a | ||
|
|
548307336a | ||
|
|
f534f90ca7 |
22
.env.ci
22
.env.ci
@@ -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=
|
||||
|
||||
16
.env.example
16
.env.example
@@ -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
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: solidtime-io
|
||||
10
.github/workflows/phpunit.yml
vendored
10
.github/workflows/phpunit.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 */
|
||||
|
||||
35
app/Enums/ExportFormat.php
Normal file
35
app/Enums/ExportFormat.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
10
app/Exceptions/Api/PdfRendererIsNotConfiguredException.php
Normal file
10
app/Exceptions/Api/PdfRendererIsNotConfiguredException.php
Normal 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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
143
app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php
Normal file
143
app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,7 +22,7 @@ class BillingContract
|
||||
*/
|
||||
public function hasSubscription(Organization $organization): bool
|
||||
{
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
17
app/Service/IntervalService.php
Normal file
17
app/Service/IntervalService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
118
app/Service/ReportExport/CsvExport.php
Normal file
118
app/Service/ReportExport/CsvExport.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
64
app/Service/ReportExport/TimeEntriesDetailedCsvExport.php
Normal file
64
app/Service/ReportExport/TimeEntriesDetailedCsvExport.php
Normal 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(', '),
|
||||
];
|
||||
}
|
||||
}
|
||||
151
app/Service/ReportExport/TimeEntriesDetailedExport.php
Normal file
151
app/Service/ReportExport/TimeEntriesDetailedExport.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
100
app/Service/ReportExport/TimeEntriesReportExport.php
Normal file
100
app/Service/ReportExport/TimeEntriesReportExport.php
Normal 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' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
933
composer.lock
generated
@@ -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
382
config/excel.php
Normal 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
11
config/services.php
Normal 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'),
|
||||
],
|
||||
];
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
BIN
public/images/solidtime-logo.png
Normal file
BIN
public/images/solidtime-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -57,7 +57,7 @@
|
||||
}
|
||||
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track {
|
||||
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -151,6 +151,7 @@ const roleDescription = computed(() => {
|
||||
v-if="billableRateSelect === 'custom-rate'">
|
||||
<InputLabel
|
||||
for="memberBillableRate"
|
||||
class="mb-2"
|
||||
value="Billable Rate" />
|
||||
<BillableRateInput
|
||||
focus
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -264,7 +264,7 @@ const disableTwoFactorAuthentication = () => {
|
||||
@confirmed="disableTwoFactorAuthentication">
|
||||
<SecondaryButton
|
||||
v-if="confirming"
|
||||
:class="{ 'opacity-25': disabling }"
|
||||
:class="disabling ? 'opacity-25' : ''"
|
||||
:disabled="disabling">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
2
resources/js/packages/api/package-lock.json
generated
2
resources/js/packages/api/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@solidtime/api",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -123,7 +123,7 @@ export type TimeEntriesQueryParams = ZodiosQueryParamsByAlias<
|
||||
export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias<
|
||||
SolidTimeApi,
|
||||
'getAggregatedTimeEntries'
|
||||
>;
|
||||
> & { start: string; end: string };
|
||||
|
||||
export type OrganizationResponse = ZodiosResponseByAlias<
|
||||
SolidTimeApi,
|
||||
|
||||
@@ -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 `time-entries:view:own` 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 `time-entries:view:own` 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 `time-entries:view:own` 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 `null` 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 `null` 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 `null` 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',
|
||||
|
||||
4
resources/js/packages/ui/package-lock.json
generated
4
resources/js/packages/ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
88
resources/js/packages/ui/src/Input/DurationHumanInput.vue
Normal file
88
resources/js/packages/ui/src/Input/DurationHumanInput.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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])"
|
||||
|
||||
269
resources/js/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue
Normal file
269
resources/js/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
7
resources/js/packages/ui/src/utils/dismissableLayer.ts
Normal file
7
resources/js/packages/ui/src/utils/dismissableLayer.ts
Normal 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
1
resources/js/types/reporting.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export type ExportFormat = 'xlsx' | 'csv' | 'ods' | 'pdf';
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ dayjs.extend(utc);
|
||||
|
||||
const emptyTimeEntry = {
|
||||
id: '',
|
||||
description: null,
|
||||
description: '',
|
||||
user_id: '',
|
||||
start: '',
|
||||
end: null,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
127
resources/views/reports/time-entry-aggregate-index.blade.php
Normal file
127
resources/views/reports/time-entry-aggregate-index.blade.php
Normal 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>
|
||||
15
resources/views/reports/time-entry-index-footer.blade.php
Normal file
15
resources/views/reports/time-entry-index-footer.blade.php
Normal 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>
|
||||
60
resources/views/reports/time-entry-index.blade.php
Normal file
60
resources/views/reports/time-entry-index.blade.php
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user