mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 16:22:16 +01:00
Compare commits
1 Commits
feature/up
...
feature/ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b84ba67cd |
22
.env.ci
22
.env.ci
@@ -6,13 +6,12 @@ APP_URL=http://localhost
|
|||||||
APP_FORCE_HTTPS=false
|
APP_FORCE_HTTPS=false
|
||||||
SESSION_SECURE_COOKIE=false
|
SESSION_SECURE_COOKIE=false
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_CONNECTION=pgsql_test
|
DB_CONNECTION=pgsql_test
|
||||||
|
|
||||||
DB_TEST_HOST=127.0.0.1
|
DB_TEST_HOST=127.0.0.1
|
||||||
DB_TEST_PORT=5432
|
DB_TEST_PORT=5432
|
||||||
DB_TEST_DATABASE=laravel
|
DB_TEST_DATABASE=laravel
|
||||||
@@ -21,21 +20,26 @@ DB_TEST_PASSWORD=root
|
|||||||
|
|
||||||
BROADCAST_DRIVER=log
|
BROADCAST_DRIVER=log
|
||||||
CACHE_DRIVER=file
|
CACHE_DRIVER=file
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=sync
|
QUEUE_CONNECTION=sync
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
# Mail
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=log
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
# Filesystems
|
S3_ACCESS_KEY_ID=
|
||||||
FILESYSTEM_DISK=local
|
S3_SECRET_ACCESS_KEY=
|
||||||
PUBLIC_FILESYSTEM_DISK=public
|
S3_REGION=us-east-1
|
||||||
|
S3_BUCKET=
|
||||||
# Services
|
S3_USE_PATH_STYLE_ENDPOINT=false
|
||||||
GOTENBERG_URL=http://0.0.0.0:3000
|
|
||||||
|
|
||||||
PUSHER_APP_ID=
|
PUSHER_APP_ID=
|
||||||
PUSHER_APP_KEY=
|
PUSHER_APP_KEY=
|
||||||
|
|||||||
16
.env.example
16
.env.example
@@ -4,15 +4,15 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=https://solidtime.test
|
APP_URL=https://solidtime.test
|
||||||
AUDITING_ENABLED=true
|
AUDITING_ENABLED=true
|
||||||
|
|
||||||
SUPER_ADMINS=admin@example.com
|
SUPER_ADMINS=admin@example.com
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_CHANNEL=single
|
LOG_CHANNEL=single
|
||||||
LOG_DEPRECATIONS_CHANNEL=deprecation
|
LOG_DEPRECATIONS_CHANNEL=deprecation
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_CONNECTION=pgsql
|
DB_CONNECTION=pgsql
|
||||||
|
|
||||||
DB_HOST=pgsql
|
DB_HOST=pgsql
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_DATABASE=laravel
|
DB_DATABASE=laravel
|
||||||
@@ -31,7 +31,12 @@ QUEUE_CONNECTION=sync
|
|||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
# Mail
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
MAIL_MAILER=smtp
|
MAIL_MAILER=smtp
|
||||||
MAIL_HOST=mailpit
|
MAIL_HOST=mailpit
|
||||||
MAIL_PORT=1025
|
MAIL_PORT=1025
|
||||||
@@ -49,7 +54,7 @@ PUSHER_PORT=443
|
|||||||
PUSHER_SCHEME=https
|
PUSHER_SCHEME=https
|
||||||
PUSHER_APP_CLUSTER=mt1
|
PUSHER_APP_CLUSTER=mt1
|
||||||
|
|
||||||
# Filesystems
|
# Storage
|
||||||
FILESYSTEM_DISK=s3
|
FILESYSTEM_DISK=s3
|
||||||
PUBLIC_FILESYSTEM_DISK=s3
|
PUBLIC_FILESYSTEM_DISK=s3
|
||||||
S3_ACCESS_KEY_ID=sail
|
S3_ACCESS_KEY_ID=sail
|
||||||
@@ -60,9 +65,6 @@ S3_URL=http://storage.solidtime.test/local
|
|||||||
S3_ENDPOINT=http://storage.solidtime.test
|
S3_ENDPOINT=http://storage.solidtime.test
|
||||||
S3_USE_PATH_STYLE_ENDPOINT=true
|
S3_USE_PATH_STYLE_ENDPOINT=true
|
||||||
|
|
||||||
# Services
|
|
||||||
GOTENBERG_URL=http://gotenberg:3000
|
|
||||||
|
|
||||||
VITE_HOST_NAME=vite.solidtime.test
|
VITE_HOST_NAME=vite.solidtime.test
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
APP_NAME=solidtime
|
APP_NAME=solidtime
|
||||||
APP_VERSION=0.0.0
|
|
||||||
APP_BUILD=0
|
|
||||||
VITE_APP_NAME=solidtime
|
VITE_APP_NAME=solidtime
|
||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
|
|||||||
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1 +0,0 @@
|
|||||||
github: solidtime-io
|
|
||||||
46
.github/workflows/build-private.yml
vendored
46
.github/workflows/build-private.yml
vendored
@@ -20,55 +20,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
|
||||||
|
|
||||||
- name: "Get build"
|
|
||||||
id: build
|
|
||||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: "Get Previous tag (normal push)"
|
|
||||||
id: previoustag
|
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
|
||||||
with:
|
|
||||||
prefix: "v"
|
|
||||||
|
|
||||||
- name: "Get version"
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
|
||||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
|
||||||
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
|
||||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "ERROR: No previous tag found";
|
|
||||||
exit 1;
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
|
||||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: "Copy .env template for production"
|
|
||||||
run: |
|
|
||||||
cp .env.production .env
|
|
||||||
rm .env.production .env.ci .env.example
|
|
||||||
|
|
||||||
- name: "Add version to .env"
|
|
||||||
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
|
|
||||||
|
|
||||||
- name: "Add build to .env"
|
|
||||||
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
|
|
||||||
|
|
||||||
- name: "Output .env"
|
|
||||||
run: cat .env
|
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
- name: "Copy .env template for production"
|
||||||
|
run: cp .env.production .env && cat .env
|
||||||
|
|
||||||
- name: "Checkout billing extension"
|
- name: "Checkout billing extension"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
90
.github/workflows/build-public-release.yml
vendored
Normal file
90
.github/workflows/build-public-release.yml
vendored
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/build-public.yml'
|
||||||
|
- 'docker/prod/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
name: Build - Public (Release)
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
timeout-minutes: 90
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: "Check out code"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: "Copy .env template for production"
|
||||||
|
run: cp .env.production .env
|
||||||
|
|
||||||
|
- name: "Install dependencies"
|
||||||
|
uses: php-actions/composer@v6
|
||||||
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
with:
|
||||||
|
command: install
|
||||||
|
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||||
|
php_version: 8.3
|
||||||
|
|
||||||
|
- name: "Use Node.js"
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.x'
|
||||||
|
|
||||||
|
- name: "Install npm dependencies"
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: "Build"
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: "Login to GitHub Container Registry"
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- name: "Login to GitHub Container Registry"
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: "Docker meta"
|
||||||
|
id: "meta"
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
solidtime/solidtime
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
|
||||||
|
- name: "Set up QEMU"
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: "Set up Docker Buildx"
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: "Build and push"
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/prod/Dockerfile
|
||||||
|
build-args: |
|
||||||
|
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
44
.github/workflows/build-public.yml
vendored
44
.github/workflows/build-public.yml
vendored
@@ -3,8 +3,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- develop
|
- develop
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-public.yml'
|
- '.github/workflows/build-public.yml'
|
||||||
@@ -25,49 +23,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
|
||||||
|
|
||||||
- name: "Get build"
|
|
||||||
id: build
|
|
||||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: "Get Previous tag (normal push)"
|
|
||||||
id: previoustag
|
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
|
||||||
with:
|
|
||||||
prefix: "v"
|
|
||||||
|
|
||||||
- name: "Get version"
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
|
||||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
|
||||||
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
|
||||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "ERROR: No previous tag found";
|
|
||||||
exit 1;
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
|
||||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: "Copy .env template for production"
|
- name: "Copy .env template for production"
|
||||||
run: |
|
run: cp .env.production .env
|
||||||
cp .env.production .env
|
|
||||||
rm .env.production .env.ci .env.example
|
|
||||||
|
|
||||||
- name: "Add version to .env"
|
|
||||||
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
|
|
||||||
|
|
||||||
- name: "Add build to .env"
|
|
||||||
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
|
|
||||||
|
|
||||||
- name: "Output .env"
|
|
||||||
run: cat .env
|
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
uses: php-actions/composer@v6
|
uses: php-actions/composer@v6
|
||||||
|
|||||||
10
.github/workflows/phpunit.yml
vendored
10
.github/workflows/phpunit.yml
vendored
@@ -20,15 +20,7 @@ jobs:
|
|||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
gotenberg:
|
|
||||||
image: gotenberg/gotenberg:8
|
|
||||||
ports:
|
|
||||||
- 3000:3000
|
|
||||||
options: >-
|
|
||||||
--health-cmd "curl --silent --fail http://localhost:3000/health"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -28,11 +28,6 @@ We also have an examples repository [here](https://github.com/solidtime-io/self-
|
|||||||
|
|
||||||
If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)
|
If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)
|
||||||
|
|
||||||
## Issues & Feature Requests
|
|
||||||
|
|
||||||
If you find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug.
|
|
||||||
If you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
This project is in a very early stage. The structure and APIs are still subject to change and not stable.
|
This project is in a very early stage. The structure and APIs are still subject to change and not stable.
|
||||||
@@ -40,8 +35,6 @@ Therefore, we do not currently accept any contributions, unless you are a member
|
|||||||
|
|
||||||
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
||||||
|
|
||||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
|
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
'email:rfc,strict',
|
'email',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(User::class, 'email', function (Builder $builder): Builder {
|
new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder {
|
||||||
/** @var Builder<User> $builder */
|
/** @var Builder<User> $builder */
|
||||||
return $builder->where('is_placeholder', '=', false);
|
return $builder->where('is_placeholder', '=', false);
|
||||||
}),
|
}),
|
||||||
@@ -82,7 +82,7 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
}
|
}
|
||||||
$user = null;
|
$user = null;
|
||||||
$organization = null;
|
$organization = null;
|
||||||
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void {
|
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency) {
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) {
|
(new UniqueEloquent(User::class, 'email'))->ignore($user->id)->query(function (Builder $query) {
|
||||||
/** @var Builder<User> $query */
|
/** @var Builder<User> $query */
|
||||||
return $query->where('is_placeholder', '=', false);
|
return $query->where('is_placeholder', '=', false);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
|
|
||||||
AddingTeamMember::dispatch($organization, $newOrganizationMember);
|
AddingTeamMember::dispatch($organization, $newOrganizationMember);
|
||||||
|
|
||||||
DB::transaction(function () use ($organization, $newOrganizationMember, $role): void {
|
DB::transaction(function () use ($organization, $newOrganizationMember, $role) {
|
||||||
$organization->users()->attach(
|
$organization->users()->attach(
|
||||||
$newOrganizationMember, ['role' => $role]
|
$newOrganizationMember, ['role' => $role]
|
||||||
);
|
);
|
||||||
@@ -71,10 +71,10 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
|
(new ExistsEloquent(User::class, 'email', function (Builder $builder) {
|
||||||
/** @var Builder<User> $builder */
|
/** @var Builder<User> $builder */
|
||||||
return $builder->where('is_placeholder', '=', false);
|
return $builder->where('is_placeholder', '=', false);
|
||||||
})->withMessage(__('We were unable to find a registered user with this email address.')),
|
}))->withMessage(__('We were unable to find a registered user with this email address.')),
|
||||||
],
|
],
|
||||||
'role' => [
|
'role' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -93,7 +93,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
*/
|
*/
|
||||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
||||||
{
|
{
|
||||||
return function ($validator) use ($team, $email): void {
|
return function ($validator) use ($team, $email) {
|
||||||
$validator->errors()->addIf(
|
$validator->errors()->addIf(
|
||||||
$team->hasRealUserWithEmail($email),
|
$team->hasRealUserWithEmail($email),
|
||||||
'email',
|
'email',
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Console\Commands\SelfHost;
|
|
||||||
|
|
||||||
use App\Service\ApiService;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
class SelfHostCheckForUpdateCommand extends Command
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The name and signature of the console command.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $signature = 'self-host:check-for-update';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The console command description.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $description = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the console command.
|
|
||||||
*/
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$apiService = app(ApiService::class);
|
|
||||||
|
|
||||||
$latestVersion = $apiService->checkForUpdate();
|
|
||||||
if ($latestVersion === null) {
|
|
||||||
$this->error('Failed to check for update, check the logs for more information.');
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Cache for 13 hours, because the command runs twice daily (every 12 hours).
|
|
||||||
Cache::put('latest_version', $latestVersion, 60 * 60 * 12);
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Console\Commands\SelfHost;
|
|
||||||
|
|
||||||
use App\Service\ApiService;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
class SelfHostTelemetryCommand extends Command
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The name and signature of the console command.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $signature = 'self-host:telemetry';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The console command description.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $description = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the console command.
|
|
||||||
*/
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$apiService = app(ApiService::class);
|
|
||||||
|
|
||||||
$success = $apiService->telemetry();
|
|
||||||
|
|
||||||
if (! $success) {
|
|
||||||
$this->error('Failed to send telemetry data, check the logs for more information.');
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -54,7 +54,7 @@ class TimeEntrySendStillRunningMailsCommand extends Command
|
|||||||
$query->where('is_placeholder', '=', false);
|
$query->where('is_placeholder', '=', false);
|
||||||
})
|
})
|
||||||
->orderBy('created_at', 'asc')
|
->orderBy('created_at', 'asc')
|
||||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
|
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) {
|
||||||
/** @var Collection<int, TimeEntry> $timeEntries */
|
/** @var Collection<int, TimeEntry> $timeEntries */
|
||||||
foreach ($timeEntries as $timeEntry) {
|
foreach ($timeEntries as $timeEntry) {
|
||||||
$user = $timeEntry->user;
|
$user = $timeEntry->user;
|
||||||
|
|||||||
@@ -17,14 +17,6 @@ class Kernel extends ConsoleKernel
|
|||||||
$schedule->command('time-entry:send-still-running-mails')
|
$schedule->command('time-entry:send-still-running-mails')
|
||||||
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
|
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
|
||||||
->everyTenMinutes();
|
->everyTenMinutes();
|
||||||
|
|
||||||
$schedule->command('self-host:check-for-update')
|
|
||||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
|
||||||
->twiceDaily();
|
|
||||||
|
|
||||||
$schedule->command('self-host:telemetry')
|
|
||||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
|
|
||||||
->twiceDaily();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<?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,12 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Enums;
|
namespace App\Enums;
|
||||||
|
|
||||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
|
||||||
|
|
||||||
enum TimeEntryAggregationType: string
|
enum TimeEntryAggregationType: string
|
||||||
{
|
{
|
||||||
use LaravelEnumHelper;
|
|
||||||
|
|
||||||
case Day = 'day';
|
case Day = 'day';
|
||||||
case Week = 'week';
|
case Week = 'week';
|
||||||
case Month = 'month';
|
case Month = 'month';
|
||||||
@@ -21,16 +17,6 @@ enum TimeEntryAggregationType: string
|
|||||||
case Billable = 'billable';
|
case Billable = 'billable';
|
||||||
case Description = 'description';
|
case Description = 'description';
|
||||||
|
|
||||||
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
|
||||||
{
|
|
||||||
return match ($timeEntryAggregationTypeInterval) {
|
|
||||||
TimeEntryAggregationTypeInterval::Day => TimeEntryAggregationType::Day,
|
|
||||||
TimeEntryAggregationTypeInterval::Week => TimeEntryAggregationType::Week,
|
|
||||||
TimeEntryAggregationTypeInterval::Month => TimeEntryAggregationType::Month,
|
|
||||||
TimeEntryAggregationTypeInterval::Year => TimeEntryAggregationType::Year,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toInterval(): ?TimeEntryAggregationTypeInterval
|
public function toInterval(): ?TimeEntryAggregationTypeInterval
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions\Api;
|
|
||||||
|
|
||||||
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
|
|
||||||
{
|
|
||||||
public const string KEY = 'feature_is_not_available_in_free_plan';
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions\Api;
|
|
||||||
|
|
||||||
class PdfRendererIsNotConfiguredException extends ApiException
|
|
||||||
{
|
|
||||||
public const string KEY = 'pdf_renderer_is_not_configured';
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ class Handler extends ExceptionHandler
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
$this->reportable(function (Throwable $e): void {
|
$this->reportable(function (Throwable $e) {
|
||||||
//
|
//
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Extensions\Scramble;
|
namespace App\Extensions\Scramble;
|
||||||
|
|
||||||
use App\Http\Resources\PaginatedResourceCollection;
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
|
|
||||||
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
|
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
|
||||||
use Dedoc\Scramble\Support\Generator\Response;
|
use Dedoc\Scramble\Support\Generator\Response;
|
||||||
use Dedoc\Scramble\Support\Generator\Schema;
|
use Dedoc\Scramble\Support\Generator\Schema;
|
||||||
@@ -45,49 +44,39 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$newType = new OpenApiObjectType;
|
$type = new OpenApiObjectType;
|
||||||
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));
|
$type->addProperty('data', (new ArrayType)->setItems($collectingType));
|
||||||
if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) {
|
$type->addProperty(
|
||||||
$newType->addProperty(
|
'links',
|
||||||
'meta',
|
(new OpenApiObjectType)
|
||||||
(new OpenApiObjectType)
|
->addProperty('first', (new StringType)->nullable(true))
|
||||||
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
->addProperty('last', (new StringType)->nullable(true))
|
||||||
->setRequired(['total'])
|
->addProperty('prev', (new StringType)->nullable(true))
|
||||||
);
|
->addProperty('next', (new StringType)->nullable(true))
|
||||||
$newType->setRequired(['data', 'meta']);
|
->setRequired(['first', 'last', 'prev', 'next'])
|
||||||
} else {
|
);
|
||||||
$newType->addProperty(
|
$type->addProperty(
|
||||||
'links',
|
'meta',
|
||||||
(new OpenApiObjectType)
|
(new OpenApiObjectType)
|
||||||
->addProperty('first', (new StringType)->nullable(true))
|
->addProperty('current_page', new IntegerType)
|
||||||
->addProperty('last', (new StringType)->nullable(true))
|
->addProperty('from', (new IntegerType)->nullable(true))
|
||||||
->addProperty('prev', (new StringType)->nullable(true))
|
->addProperty('last_page', new IntegerType)
|
||||||
->addProperty('next', (new StringType)->nullable(true))
|
->addProperty('links', (new ArrayType)->setItems(
|
||||||
->setRequired(['first', 'last', 'prev', 'next'])
|
(new OpenApiObjectType)
|
||||||
);
|
->addProperty('url', (new StringType)->nullable(true))
|
||||||
$newType->addProperty(
|
->addProperty('label', new StringType)
|
||||||
'meta',
|
->addProperty('active', new BooleanType)
|
||||||
(new OpenApiObjectType)
|
->setRequired(['url', 'label', 'active'])
|
||||||
->addProperty('current_page', new IntegerType)
|
)->setDescription('Generated paginator links.'))
|
||||||
->addProperty('from', (new IntegerType)->nullable(true))
|
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
|
||||||
->addProperty('last_page', new IntegerType)
|
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
|
||||||
->addProperty('links', (new ArrayType)->setItems(
|
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
|
||||||
(new OpenApiObjectType)
|
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
||||||
->addProperty('url', (new StringType)->nullable(true))
|
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
|
||||||
->addProperty('label', new StringType)
|
);
|
||||||
->addProperty('active', new BooleanType)
|
$type->setRequired(['data', 'links', 'meta']);
|
||||||
->setRequired(['url', 'label', 'active'])
|
|
||||||
)->setDescription('Generated paginator links.'))
|
|
||||||
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
|
|
||||||
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
|
|
||||||
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
|
|
||||||
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
|
||||||
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
|
|
||||||
);
|
|
||||||
$newType->setRequired(['data', 'links', 'meta']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $newType;
|
return $type;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ class OrganizationResource extends Resource
|
|||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'gt:0',
|
'gt:0',
|
||||||
'max:2147483647',
|
|
||||||
])
|
])
|
||||||
->numeric(),
|
->numeric(),
|
||||||
Forms\Components\DateTimePicker::make('created_at')
|
Forms\Components\DateTimePicker::make('created_at')
|
||||||
@@ -123,7 +122,7 @@ class OrganizationResource extends Resource
|
|||||||
->persistent()
|
->persistent()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return response()->streamDownload(function () use ($file): void {
|
return response()->streamDownload(function () use ($file) {
|
||||||
echo Storage::disk(config('filesystems.private'))->get($file);
|
echo Storage::disk(config('filesystems.private'))->get($file);
|
||||||
}, 'export.zip');
|
}, 'export.zip');
|
||||||
} catch (\Exception $exception) {
|
} catch (\Exception $exception) {
|
||||||
@@ -138,7 +137,7 @@ class OrganizationResource extends Resource
|
|||||||
}),
|
}),
|
||||||
Action::make('Import')
|
Action::make('Import')
|
||||||
->icon('heroicon-o-inbox-arrow-down')
|
->icon('heroicon-o-inbox-arrow-down')
|
||||||
->action(function (Organization $record, array $data): void {
|
->action(function (Organization $record, array $data) {
|
||||||
try {
|
try {
|
||||||
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
|
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
|
||||||
if ($file === null) {
|
if ($file === null) {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ class ProjectMemberResource extends Resource
|
|||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'gt:0',
|
'gt:0',
|
||||||
'max:2147483647',
|
|
||||||
])
|
])
|
||||||
->numeric(),
|
->numeric(),
|
||||||
Forms\Components\Select::make('user_id')
|
Forms\Components\Select::make('user_id')
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ class ProjectResource extends Resource
|
|||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'gt:0',
|
'gt:0',
|
||||||
'max:2147483647',
|
|
||||||
])
|
])
|
||||||
->numeric(),
|
->numeric(),
|
||||||
Forms\Components\Select::make('organization_id')
|
Forms\Components\Select::make('organization_id')
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets;
|
|
||||||
|
|
||||||
use Filament\Widgets\Widget;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
class ServerOverview extends Widget
|
|
||||||
{
|
|
||||||
protected static string $view = 'filament.widgets.server-overview';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function getViewData(): array
|
|
||||||
{
|
|
||||||
/** @var string|null $currentVersion */
|
|
||||||
$currentVersion = config('app.version');
|
|
||||||
/** @var string|null $build */
|
|
||||||
$build = config('app.build');
|
|
||||||
$latestVersion = Cache::get('latest_version', null);
|
|
||||||
|
|
||||||
$needsUpdate = false;
|
|
||||||
if ($latestVersion !== null && $currentVersion !== null && version_compare($latestVersion, $currentVersion) > 0) {
|
|
||||||
$needsUpdate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'version' => $currentVersion,
|
|
||||||
'build' => $build,
|
|
||||||
'environment' => config('app.env'),
|
|
||||||
'currentVersion' => $latestVersion,
|
|
||||||
'needsUpdate' => $needsUpdate,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Service\BillingContract;
|
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
|
||||||
@@ -44,9 +43,4 @@ class Controller extends \App\Http\Controllers\Controller
|
|||||||
{
|
{
|
||||||
return $this->permissionStore->has($organization, $permission);
|
return $this->permissionStore->has($organization, $permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function canAccessPremiumFeatures(Organization $organization): bool
|
|
||||||
{
|
|
||||||
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
@@ -24,9 +23,7 @@ class OrganizationController extends Controller
|
|||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'organizations:view');
|
$this->checkPermission($organization, 'organizations:view');
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
return new OrganizationResource($organization);
|
||||||
|
|
||||||
return new OrganizationResource($organization, $showBillableRate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,9 +39,6 @@ class OrganizationController extends Controller
|
|||||||
|
|
||||||
$organization->name = $request->input('name');
|
$organization->name = $request->input('name');
|
||||||
$oldBillableRate = $organization->billable_rate;
|
$oldBillableRate = $organization->billable_rate;
|
||||||
if ($request->has('employees_can_see_billable_rates')) {
|
|
||||||
$organization->employees_can_see_billable_rates = $request->validated('employees_can_see_billable_rates');
|
|
||||||
}
|
|
||||||
$organization->billable_rate = $request->getBillableRate();
|
$organization->billable_rate = $request->getBillableRate();
|
||||||
$organization->save();
|
$organization->save();
|
||||||
|
|
||||||
@@ -52,6 +46,6 @@ class OrganizationController extends Controller
|
|||||||
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
|
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OrganizationResource($organization, true);
|
return new OrganizationResource($organization);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
use App\Http\Requests\V1\Project\ProjectIndexRequest;
|
use App\Http\Requests\V1\Project\ProjectIndexRequest;
|
||||||
use App\Http\Requests\V1\Project\ProjectStoreRequest;
|
use App\Http\Requests\V1\Project\ProjectStoreRequest;
|
||||||
@@ -14,7 +13,6 @@ use App\Http\Resources\V1\Project\ProjectResource;
|
|||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Models\TimeEntry;
|
|
||||||
use App\Service\BillableRateService;
|
use App\Service\BillableRateService;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -62,9 +60,7 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
|
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
return new ProjectCollection($projects);
|
||||||
|
|
||||||
return new ProjectCollection($projects, $showBillableRate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,12 +74,9 @@ class ProjectController extends Controller
|
|||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'projects:view', $project);
|
$this->checkPermission($organization, 'projects:view', $project);
|
||||||
|
|
||||||
// Note: There is currently no need to check if a user is a member of the project,
|
|
||||||
// since this is only relevant for users with the role "employee" and they can not access this endpoint.
|
|
||||||
|
|
||||||
$project->load('organization');
|
$project->load('organization');
|
||||||
|
|
||||||
return new ProjectResource($project, true);
|
return new ProjectResource($project);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,13 +95,10 @@ class ProjectController extends Controller
|
|||||||
$project->is_billable = (bool) $request->input('is_billable');
|
$project->is_billable = (bool) $request->input('is_billable');
|
||||||
$project->billable_rate = $request->getBillableRate();
|
$project->billable_rate = $request->getBillableRate();
|
||||||
$project->client_id = $request->input('client_id');
|
$project->client_id = $request->input('client_id');
|
||||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
|
||||||
$project->estimated_time = $request->getEstimatedTime();
|
|
||||||
}
|
|
||||||
$project->organization()->associate($organization);
|
$project->organization()->associate($organization);
|
||||||
$project->save();
|
$project->save();
|
||||||
|
|
||||||
return new ProjectResource($project, true);
|
return new ProjectResource($project);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,29 +117,16 @@ class ProjectController extends Controller
|
|||||||
if ($request->has('is_archived')) {
|
if ($request->has('is_archived')) {
|
||||||
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
|
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
|
||||||
}
|
}
|
||||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
|
||||||
$project->estimated_time = $request->getEstimatedTime();
|
|
||||||
}
|
|
||||||
$oldBillableRate = $project->billable_rate;
|
$oldBillableRate = $project->billable_rate;
|
||||||
$clientIdChanged = false;
|
|
||||||
$project->billable_rate = $request->getBillableRate();
|
$project->billable_rate = $request->getBillableRate();
|
||||||
if ($project->client_id !== $request->input('client_id')) {
|
$project->client_id = $request->input('client_id');
|
||||||
$project->client_id = $request->input('client_id');
|
|
||||||
$clientIdChanged = true;
|
|
||||||
}
|
|
||||||
$project->save();
|
$project->save();
|
||||||
|
|
||||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||||
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
||||||
}
|
}
|
||||||
if ($clientIdChanged) {
|
|
||||||
TimeEntry::query()
|
|
||||||
->whereBelongsTo($organization, 'organization')
|
|
||||||
->whereBelongsTo($project, 'project')
|
|
||||||
->update(['client_id' => $project->client_id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ProjectResource($project, true);
|
return new ProjectResource($project);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,8 +147,8 @@ class ProjectController extends Controller
|
|||||||
throw new EntityStillInUseApiException('project', 'time_entry');
|
throw new EntityStillInUseApiException('project', 'time_entry');
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::transaction(function () use (&$project): void {
|
DB::transaction(function () use (&$project) {
|
||||||
$project->members->each(function (ProjectMember $member): void {
|
$project->members->each(function (ProjectMember $member) {
|
||||||
$member->delete();
|
$member->delete();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -79,9 +79,6 @@ class TaskController extends Controller
|
|||||||
$task = new Task;
|
$task = new Task;
|
||||||
$task->name = $request->input('name');
|
$task->name = $request->input('name');
|
||||||
$task->project_id = $request->input('project_id');
|
$task->project_id = $request->input('project_id');
|
||||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
|
||||||
$task->estimated_time = $request->getEstimatedTime();
|
|
||||||
}
|
|
||||||
$task->organization()->associate($organization);
|
$task->organization()->associate($organization);
|
||||||
$task->save();
|
$task->save();
|
||||||
|
|
||||||
@@ -99,9 +96,6 @@ class TaskController extends Controller
|
|||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'tasks:update', $task);
|
$this->checkPermission($organization, 'tasks:update', $task);
|
||||||
$task->name = $request->input('name');
|
$task->name = $request->input('name');
|
||||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
|
||||||
$task->estimated_time = $request->getEstimatedTime();
|
|
||||||
}
|
|
||||||
if ($request->has('is_done')) {
|
if ($request->has('is_done')) {
|
||||||
$task->done_at = $request->getIsDone() ? Carbon::now() : null;
|
$task->done_at = $request->getIsDone() ? Carbon::now() : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,51 +4,28 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\ExportFormat;
|
|
||||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
|
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryDestroyMultipleRequest;
|
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexExportRequest;
|
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
|
||||||
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
|
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
|
||||||
use App\Http\Resources\V1\TimeEntry\TimeEntryResource;
|
use App\Http\Resources\V1\TimeEntry\TimeEntryResource;
|
||||||
use App\Jobs\RecalculateSpentTimeForProject;
|
|
||||||
use App\Jobs\RecalculateSpentTimeForTask;
|
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Task;
|
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
|
|
||||||
use App\Service\ReportExport\TimeEntriesDetailedExport;
|
|
||||||
use App\Service\ReportExport\TimeEntriesReportExport;
|
|
||||||
use App\Service\TimeEntryAggregationService;
|
use App\Service\TimeEntryAggregationService;
|
||||||
use App\Service\TimeEntryFilter;
|
use App\Service\TimeEntryFilter;
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
use Gotenberg\Exceptions\GotenbergApiErrored;
|
|
||||||
use Gotenberg\Exceptions\NoOutputFileInResponse;
|
|
||||||
use Gotenberg\Gotenberg;
|
|
||||||
use Gotenberg\Stream;
|
|
||||||
use GuzzleHttp\Client;
|
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Http\File;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Blade;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
|
||||||
|
|
||||||
class TimeEntryController extends Controller
|
class TimeEntryController extends Controller
|
||||||
{
|
{
|
||||||
@@ -61,13 +38,11 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get time entries in organization
|
* Get all time entries in organization
|
||||||
*
|
*
|
||||||
* If you only need time entries for a specific user, you can filter by `user_id`.
|
* If you only need time entries for a specific user, you can filter by `user_id`.
|
||||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
|
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
|
||||||
*
|
*
|
||||||
* @return TimeEntryCollection<TimeEntryResource>
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId getTimeEntries
|
* @operationId getTimeEntries
|
||||||
@@ -82,16 +57,27 @@ class TimeEntryController extends Controller
|
|||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
$timeEntriesQuery = TimeEntry::query()
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->orderBy('start', 'desc');
|
||||||
|
|
||||||
$totalCount = $timeEntriesQuery->count();
|
$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'));
|
||||||
|
|
||||||
$limit = $request->getLimit();
|
$limit = $request->has('limit') ? (int) $request->input('limit', 100) : 100;
|
||||||
if ($limit > 1000) {
|
if ($limit > 1000) {
|
||||||
$limit = 1000;
|
$limit = 1000;
|
||||||
}
|
}
|
||||||
$timeEntriesQuery->limit($limit);
|
$timeEntriesQuery->limit($limit);
|
||||||
$timeEntriesQuery->skip($request->getOffset());
|
|
||||||
|
|
||||||
$timeEntries = $timeEntriesQuery->get();
|
$timeEntries = $timeEntriesQuery->get();
|
||||||
|
|
||||||
@@ -125,121 +111,7 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (new TimeEntryCollection($timeEntries))
|
return new TimeEntryCollection($timeEntries);
|
||||||
->additional([
|
|
||||||
'meta' => [
|
|
||||||
'total' => $totalCount,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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)),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -274,7 +146,7 @@ class TimeEntryController extends Controller
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $aggregationService): array
|
||||||
{
|
{
|
||||||
/** @var Member|null $member */
|
/** @var Member|null $member */
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
@@ -283,146 +155,7 @@ class TimeEntryController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
$user = $this->user();
|
|
||||||
|
|
||||||
$group1Type = $request->getGroup();
|
|
||||||
$group2Type = $request->getSubGroup();
|
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
|
||||||
|
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
|
||||||
$timeEntriesAggregateQuery,
|
|
||||||
$group1Type,
|
|
||||||
$group2Type,
|
|
||||||
$user->timezone,
|
|
||||||
$user->week_start,
|
|
||||||
$request->getFillGapsInTimeGroups(),
|
|
||||||
$request->getStart(),
|
|
||||||
$request->getEnd()
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'data' => $aggregatedData,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export aggregated time entries in organization
|
|
||||||
*
|
|
||||||
* @operationId exportAggregatedTimeEntries
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
|
||||||
* @throws PdfRendererIsNotConfiguredException
|
|
||||||
* @throws GotenbergApiErrored
|
|
||||||
* @throws NoOutputFileInResponse
|
|
||||||
* @throws FeatureIsNotAvailableInFreePlanApiException
|
|
||||||
*/
|
|
||||||
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
|
||||||
{
|
|
||||||
/** @var Member|null $member */
|
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
|
||||||
if ($member !== null && $member->user_id === Auth::id()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
|
||||||
} else {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
|
||||||
}
|
|
||||||
$format = $request->getFormatValue();
|
|
||||||
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
|
||||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
|
||||||
}
|
|
||||||
$user = $this->user();
|
|
||||||
|
|
||||||
$group = $request->getGroup();
|
|
||||||
$subGroup = $request->getSubGroup();
|
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
|
||||||
|
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
|
||||||
$timeEntriesAggregateQuery->clone(),
|
|
||||||
$group,
|
|
||||||
$subGroup,
|
|
||||||
$user->timezone,
|
|
||||||
$user->week_start,
|
|
||||||
false,
|
|
||||||
$request->getStart(),
|
|
||||||
$request->getEnd()
|
|
||||||
);
|
|
||||||
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
|
||||||
$timeEntriesAggregateQuery->clone(),
|
|
||||||
$request->getHistoryGroup(),
|
|
||||||
null,
|
|
||||||
$user->timezone,
|
|
||||||
$user->week_start,
|
|
||||||
true,
|
|
||||||
$request->getStart(),
|
|
||||||
$request->getEnd()
|
|
||||||
);
|
|
||||||
$currency = $organization->currency;
|
|
||||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
|
||||||
|
|
||||||
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
|
||||||
$folderPath = 'exports';
|
|
||||||
$path = $folderPath.'/'.$filename;
|
|
||||||
|
|
||||||
if ($format === ExportFormat::PDF) {
|
|
||||||
if (config('services.gotenberg.url') === null) {
|
|
||||||
throw new PdfRendererIsNotConfiguredException;
|
|
||||||
}
|
|
||||||
$client = new Client([
|
|
||||||
'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [
|
|
||||||
config('services.gotenberg.basic_auth_username'),
|
|
||||||
config('services.gotenberg.basic_auth_password'),
|
|
||||||
] : null,
|
|
||||||
]);
|
|
||||||
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php'));
|
|
||||||
if ($viewFile === false) {
|
|
||||||
throw new \LogicException('View file not found');
|
|
||||||
}
|
|
||||||
$html = Blade::render($viewFile, [
|
|
||||||
'aggregatedData' => $aggregatedData,
|
|
||||||
'dataHistoryChart' => $dataHistoryChart,
|
|
||||||
'currency' => $currency,
|
|
||||||
'group' => $group,
|
|
||||||
'subGroup' => $subGroup,
|
|
||||||
'start' => $request->getStart()->timezone($timezone),
|
|
||||||
'end' => $request->getEnd()->timezone($timezone),
|
|
||||||
]);
|
|
||||||
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
|
|
||||||
if ($footerViewFile === false) {
|
|
||||||
throw new \LogicException('View file not found');
|
|
||||||
}
|
|
||||||
$footerHtml = Blade::render($footerViewFile);
|
|
||||||
$request = Gotenberg::chromium(config('services.gotenberg.url'))
|
|
||||||
->pdf()
|
|
||||||
->pdfa('PDF/A-3b')
|
|
||||||
->paperSize('8.27', '11.7') // A4
|
|
||||||
->footer(Stream::string('footer', $footerHtml))
|
|
||||||
->html(Stream::string('body', $html));
|
|
||||||
$tempFolder = TemporaryDirectory::make();
|
|
||||||
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);
|
|
||||||
Storage::disk(config('filesystems.private'))
|
|
||||||
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
|
|
||||||
} else {
|
|
||||||
Excel::store(
|
|
||||||
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup),
|
|
||||||
$path,
|
|
||||||
config('filesystems.private'),
|
|
||||||
$format->getExportPackageType(),
|
|
||||||
[
|
|
||||||
'visibility' => 'private',
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'download_url' => Storage::disk(config('filesystems.private'))
|
|
||||||
->temporaryUrl($path, now()->addMinutes(5)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Builder<TimeEntry>
|
|
||||||
*/
|
|
||||||
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
|
|
||||||
{
|
|
||||||
$timeEntriesQuery = TimeEntry::query()
|
$timeEntriesQuery = TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization');
|
->whereBelongsTo($organization, 'organization');
|
||||||
|
|
||||||
@@ -437,8 +170,27 @@ class TimeEntryController extends Controller
|
|||||||
$filter->addTaskIdsFilter($request->input('task_ids'));
|
$filter->addTaskIdsFilter($request->input('task_ids'));
|
||||||
$filter->addClientIdsFilter($request->input('client_ids'));
|
$filter->addClientIdsFilter($request->input('client_ids'));
|
||||||
$filter->addBillableFilter($request->input('billable'));
|
$filter->addBillableFilter($request->input('billable'));
|
||||||
|
$timeEntriesQuery = $filter->get();
|
||||||
|
|
||||||
return $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,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -463,16 +215,7 @@ class TimeEntryController extends Controller
|
|||||||
throw new TimeEntryStillRunningApiException;
|
throw new TimeEntryStillRunningApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
|
$client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null;
|
||||||
$client = $project?->client;
|
|
||||||
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
|
|
||||||
|
|
||||||
if ($project !== null) {
|
|
||||||
RecalculateSpentTimeForProject::dispatch($project);
|
|
||||||
}
|
|
||||||
if ($task !== null) {
|
|
||||||
RecalculateSpentTimeForTask::dispatch($task);
|
|
||||||
}
|
|
||||||
|
|
||||||
$timeEntry = new TimeEntry;
|
$timeEntry = new TimeEntry;
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
@@ -507,38 +250,16 @@ class TimeEntryController extends Controller
|
|||||||
throw new TimeEntryCanNotBeRestartedApiException;
|
throw new TimeEntryCanNotBeRestartedApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
$oldProject = $timeEntry->project;
|
|
||||||
$oldTask = $timeEntry->task;
|
|
||||||
|
|
||||||
$project = null;
|
|
||||||
if ($request->has('project_id')) {
|
if ($request->has('project_id')) {
|
||||||
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
|
$client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null;
|
||||||
$client = $project?->client;
|
|
||||||
$timeEntry->client()->associate($client);
|
$timeEntry->client()->associate($client);
|
||||||
}
|
}
|
||||||
$task = null;
|
|
||||||
if ($request->has('task_id')) {
|
|
||||||
$task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
||||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||||
$timeEntry->save();
|
$timeEntry->save();
|
||||||
|
|
||||||
if ($oldProject !== null) {
|
|
||||||
RecalculateSpentTimeForProject::dispatch($oldProject);
|
|
||||||
}
|
|
||||||
if ($oldTask !== null) {
|
|
||||||
RecalculateSpentTimeForTask::dispatch($oldTask);
|
|
||||||
}
|
|
||||||
if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {
|
|
||||||
RecalculateSpentTimeForProject::dispatch($project);
|
|
||||||
}
|
|
||||||
if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {
|
|
||||||
RecalculateSpentTimeForTask::dispatch($task);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TimeEntryResource($timeEntry);
|
return new TimeEntryResource($timeEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,37 +279,22 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$timeEntries = TimeEntry::query()
|
$timeEntries = TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->with([
|
|
||||||
'project',
|
|
||||||
'task',
|
|
||||||
])
|
|
||||||
->whereIn('id', $ids)
|
->whereIn('id', $ids)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$changes = $request->validated('changes');
|
$changes = $request->validated('changes');
|
||||||
|
|
||||||
if ($request->has('changes.description')) {
|
|
||||||
$changes['description'] = $request->input('changes.description') ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) {
|
if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) {
|
||||||
throw new AuthorizationException;
|
throw new AuthorizationException;
|
||||||
}
|
}
|
||||||
|
|
||||||
$project = null;
|
|
||||||
$client = null;
|
$client = null;
|
||||||
$overwriteClient = false;
|
$overwriteClient = false;
|
||||||
if ($request->has('changes.project_id')) {
|
if ($request->has('changes.project_id')) {
|
||||||
$project = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id')) : null;
|
$client = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id'))->client : null;
|
||||||
$client = $project?->client;
|
|
||||||
$overwriteClient = true;
|
$overwriteClient = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$task = null;
|
|
||||||
if ($request->has('changes.task_id')) {
|
|
||||||
$task = $request->input('changes.task_id') !== null ? Task::findOrFail((string) $request->input('changes.task_id')) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$success = new Collection;
|
$success = new Collection;
|
||||||
$error = new Collection;
|
$error = new Collection;
|
||||||
|
|
||||||
@@ -607,32 +313,12 @@ class TimeEntryController extends Controller
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
}
|
}
|
||||||
$oldProject = $timeEntry->project;
|
|
||||||
$oldTask = $timeEntry->task;
|
|
||||||
|
|
||||||
$timeEntry->fill($changes);
|
$timeEntry->fill($changes);
|
||||||
// If project is changed, but task is not, we remove the old task from the time entry
|
|
||||||
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
|
|
||||||
$timeEntry->task()->disassociate();
|
|
||||||
}
|
|
||||||
if ($overwriteClient) {
|
if ($overwriteClient) {
|
||||||
$timeEntry->client()->associate($client);
|
$timeEntry->client()->associate($client);
|
||||||
}
|
}
|
||||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||||
$timeEntry->save();
|
$timeEntry->save();
|
||||||
if ($oldTask !== null) {
|
|
||||||
RecalculateSpentTimeForTask::dispatch($oldTask);
|
|
||||||
}
|
|
||||||
if ($oldProject !== null) {
|
|
||||||
RecalculateSpentTimeForProject::dispatch($oldProject);
|
|
||||||
}
|
|
||||||
if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {
|
|
||||||
RecalculateSpentTimeForProject::dispatch($project);
|
|
||||||
}
|
|
||||||
if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {
|
|
||||||
RecalculateSpentTimeForTask::dispatch($task);
|
|
||||||
}
|
|
||||||
|
|
||||||
$success->push($id);
|
$success->push($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,81 +343,9 @@ class TimeEntryController extends Controller
|
|||||||
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
$project = $timeEntry->project;
|
|
||||||
$task = $timeEntry->task;
|
|
||||||
|
|
||||||
$timeEntry->delete();
|
$timeEntry->delete();
|
||||||
|
|
||||||
if ($project !== null) {
|
|
||||||
RecalculateSpentTimeForProject::dispatch($project);
|
|
||||||
}
|
|
||||||
if ($task !== null) {
|
|
||||||
RecalculateSpentTimeForTask::dispatch($task);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()
|
return response()
|
||||||
->json(null, 204);
|
->json(null, 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete multiple time entries
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*
|
|
||||||
* @operationId deleteTimeEntries
|
|
||||||
*/
|
|
||||||
public function destroyMultiple(Organization $organization, TimeEntryDestroyMultipleRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
$this->checkAnyPermission($organization, ['time-entries:delete:all', 'time-entries:delete:own']);
|
|
||||||
$canDeleteAll = $this->hasPermission($organization, 'time-entries:delete:all');
|
|
||||||
|
|
||||||
$ids = $request->validated('ids');
|
|
||||||
$timeEntries = TimeEntry::query()
|
|
||||||
->whereBelongsTo($organization, 'organization')
|
|
||||||
->with([
|
|
||||||
'project',
|
|
||||||
'task',
|
|
||||||
])
|
|
||||||
->whereIn('id', $ids)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$success = new Collection;
|
|
||||||
$error = new Collection;
|
|
||||||
|
|
||||||
foreach ($ids as $id) {
|
|
||||||
/** @var TimeEntry|null $timeEntry */
|
|
||||||
$timeEntry = $timeEntries->firstWhere('id', $id);
|
|
||||||
if ($timeEntry === null) {
|
|
||||||
// Note: ID wrong or time entry in different organization
|
|
||||||
$error->push($id);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
|
|
||||||
$error->push($id);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
$project = $timeEntry->project;
|
|
||||||
$task = $timeEntry->task;
|
|
||||||
|
|
||||||
$timeEntry->delete();
|
|
||||||
|
|
||||||
if ($project !== null) {
|
|
||||||
RecalculateSpentTimeForProject::dispatch($project);
|
|
||||||
}
|
|
||||||
if ($task !== null) {
|
|
||||||
RecalculateSpentTimeForTask::dispatch($task);
|
|
||||||
}
|
|
||||||
$success->push($id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => $success->toArray(),
|
|
||||||
'error' => $error->toArray(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ class HealthCheckController extends Controller
|
|||||||
return response()
|
return response()
|
||||||
->json([
|
->json([
|
||||||
'ip_address' => $ipAddress,
|
'ip_address' => $ipAddress,
|
||||||
'url' => $request->url(),
|
|
||||||
'path' => $request->path(),
|
|
||||||
'hostname' => $hostname,
|
'hostname' => $hostname,
|
||||||
'timestamp' => Carbon::now()->timestamp,
|
'timestamp' => Carbon::now()->timestamp,
|
||||||
'date_time_utc' => Carbon::now('UTC')->toDateTimeString(),
|
'date_time_utc' => Carbon::now('UTC')->toDateTimeString(),
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class ClientIndexRequest extends FormRequest
|
|||||||
'page' => [
|
'page' => [
|
||||||
'integer',
|
'integer',
|
||||||
'min:1',
|
'min:1',
|
||||||
'max:2147483647',
|
|
||||||
],
|
],
|
||||||
'archived' => [
|
'archived' => [
|
||||||
'string',
|
'string',
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ class ClientStoreRequest extends FormRequest
|
|||||||
'string',
|
'string',
|
||||||
'min:1',
|
'min:1',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(Client::class, 'name', function (Builder $builder): Builder {
|
(new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder {
|
||||||
/** @var Builder<Client> $builder */
|
/** @var Builder<Client> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->withCustomTranslation('validation.client_name_already_exists'),
|
}))->withCustomTranslation('validation.client_name_already_exists'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ class ClientUpdateRequest extends FormRequest
|
|||||||
'string',
|
'string',
|
||||||
'min:1',
|
'min:1',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(Client::class, 'name', function (Builder $builder): Builder {
|
(new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder {
|
||||||
/** @var Builder<Client> $builder */
|
/** @var Builder<Client> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->ignore($this->client?->getKey())->withCustomTranslation('validation.client_name_already_exists'),
|
}))->ignore($this->client?->getKey())->withCustomTranslation('validation.client_name_already_exists'),
|
||||||
],
|
],
|
||||||
'is_archived' => [
|
'is_archived' => [
|
||||||
'boolean',
|
'boolean',
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ class InvitationStoreRequest extends FormRequest
|
|||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
UniqueEloquent::make(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
|
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
|
||||||
/** @var Builder<OrganizationInvitation> $builder */
|
/** @var Builder<OrganizationInvitation> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->withCustomTranslation('validation.invitation_already_exists'),
|
}))->withCustomTranslation('validation.invitation_already_exists'),
|
||||||
],
|
],
|
||||||
'role' => [
|
'role' => [
|
||||||
'required',
|
'required',
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ class MemberUpdateRequest extends FormRequest
|
|||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'min:0',
|
'min:0',
|
||||||
'max:2147483647',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ class OrganizationUpdateRequest extends FormRequest
|
|||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'min:0',
|
'min:0',
|
||||||
'max:2147483647',
|
|
||||||
],
|
|
||||||
'employees_can_see_billable_rates' => [
|
|
||||||
'boolean',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class ProjectIndexRequest extends FormRequest
|
|||||||
'page' => [
|
'page' => [
|
||||||
'integer',
|
'integer',
|
||||||
'min:1',
|
'min:1',
|
||||||
'max:2147483647',
|
|
||||||
],
|
],
|
||||||
'archived' => [
|
'archived' => [
|
||||||
'string',
|
'string',
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ class ProjectStoreRequest extends FormRequest
|
|||||||
'string',
|
'string',
|
||||||
'min:1',
|
'min:1',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
|
(new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->withCustomTranslation('validation.project_name_already_exists'),
|
}))->withCustomTranslation('validation.project_name_already_exists'),
|
||||||
],
|
],
|
||||||
'color' => [
|
'color' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -51,22 +51,13 @@ class ProjectStoreRequest extends FormRequest
|
|||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'min:0',
|
'min:0',
|
||||||
'max:2147483647',
|
|
||||||
],
|
],
|
||||||
// ID of the client
|
|
||||||
'client_id' => [
|
'client_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Client> $builder */
|
/** @var Builder<Client> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
|
||||||
// Estimated time in seconds
|
|
||||||
'estimated_time' => [
|
|
||||||
'nullable',
|
|
||||||
'integer',
|
|
||||||
'min:0',
|
|
||||||
'max:2147483647',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -77,11 +68,4 @@ class ProjectStoreRequest extends FormRequest
|
|||||||
|
|
||||||
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEstimatedTime(): ?int
|
|
||||||
{
|
|
||||||
$input = $this->input('estimated_time');
|
|
||||||
|
|
||||||
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ class ProjectUpdateRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
|
(new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),
|
}))->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),
|
||||||
],
|
],
|
||||||
'color' => [
|
'color' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -52,23 +52,15 @@ class ProjectUpdateRequest extends FormRequest
|
|||||||
],
|
],
|
||||||
'client_id' => [
|
'client_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Client> $builder */
|
/** @var Builder<Client> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
'billable_rate' => [
|
'billable_rate' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'min:0',
|
'min:0',
|
||||||
'max:2147483647',
|
|
||||||
],
|
|
||||||
// Estimated time in seconds
|
|
||||||
'estimated_time' => [
|
|
||||||
'nullable',
|
|
||||||
'integer',
|
|
||||||
'min:0',
|
|
||||||
'max:2147483647',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -86,11 +78,4 @@ class ProjectUpdateRequest extends FormRequest
|
|||||||
|
|
||||||
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEstimatedTime(): ?int
|
|
||||||
{
|
|
||||||
$input = $this->input('estimated_time');
|
|
||||||
|
|
||||||
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,16 +26,16 @@ class ProjectMemberStoreRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'member_id' => [
|
'member_id' => [
|
||||||
'required',
|
'required',
|
||||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Member> $builder */
|
/** @var Builder<Member> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
'billable_rate' => [
|
'billable_rate' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'min:0',
|
'min:0',
|
||||||
'max:2147483647',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ class ProjectMemberUpdateRequest extends FormRequest
|
|||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'min:0',
|
'min:0',
|
||||||
'max:2147483647',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ class TagStoreRequest extends FormRequest
|
|||||||
'string',
|
'string',
|
||||||
'min:1',
|
'min:1',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(Tag::class, 'name', function (Builder $builder): Builder {
|
(new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder {
|
||||||
/** @var Builder<Tag> $builder */
|
/** @var Builder<Tag> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->withCustomTranslation('validation.tag_name_already_exists'),
|
}))->withCustomTranslation('validation.tag_name_already_exists'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ class TagUpdateRequest extends FormRequest
|
|||||||
'string',
|
'string',
|
||||||
'min:1',
|
'min:1',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(Tag::class, 'name', function (Builder $builder): Builder {
|
(new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder {
|
||||||
/** @var Builder<Tag> $builder */
|
/** @var Builder<Tag> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->ignore($this->tag?->getKey())->withCustomTranslation('validation.tag_name_already_exists'),
|
}))->ignore($this->tag?->getKey())->withCustomTranslation('validation.tag_name_already_exists'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ class TaskIndexRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'project_id' => [
|
'project_id' => [
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ class TaskIndexRequest extends FormRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $builder;
|
return $builder;
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
'done' => [
|
'done' => [
|
||||||
'string',
|
'string',
|
||||||
|
|||||||
@@ -31,32 +31,18 @@ class TaskStoreRequest extends FormRequest
|
|||||||
'string',
|
'string',
|
||||||
'min:1',
|
'min:1',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(Task::class, 'name', function (Builder $builder): Builder {
|
(new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder {
|
||||||
/** @var Builder<Task> $builder */
|
/** @var Builder<Task> $builder */
|
||||||
return $builder->where('project_id', '=', $this->input('project_id'));
|
return $builder->where('project_id', '=', $this->input('project_id'));
|
||||||
})->withCustomTranslation('validation.task_name_already_exists'),
|
}))->withCustomTranslation('validation.task_name_already_exists'),
|
||||||
],
|
],
|
||||||
'project_id' => [
|
'project_id' => [
|
||||||
'required',
|
'required',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
|
||||||
// Estimated time in seconds
|
|
||||||
'estimated_time' => [
|
|
||||||
'nullable',
|
|
||||||
'integer',
|
|
||||||
'min:0',
|
|
||||||
'max:2147483647',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEstimatedTime(): ?int
|
|
||||||
{
|
|
||||||
$input = $this->input('estimated_time');
|
|
||||||
|
|
||||||
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,21 +30,14 @@ class TaskUpdateRequest extends FormRequest
|
|||||||
'string',
|
'string',
|
||||||
'min:1',
|
'min:1',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(Task::class, 'name', function (Builder $builder): Builder {
|
(new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder {
|
||||||
/** @var Builder<Task> $builder */
|
/** @var Builder<Task> $builder */
|
||||||
return $builder->where('project_id', '=', $this->task->project_id);
|
return $builder->where('project_id', '=', $this->task->project_id);
|
||||||
})->ignore($this->task?->getKey())->withCustomTranslation('validation.task_name_already_exists'),
|
}))->ignore($this->task?->getKey())->withCustomTranslation('validation.task_name_already_exists'),
|
||||||
],
|
],
|
||||||
'is_done' => [
|
'is_done' => [
|
||||||
'boolean',
|
'boolean',
|
||||||
],
|
],
|
||||||
// Estimated time in seconds
|
|
||||||
'estimated_time' => [
|
|
||||||
'nullable',
|
|
||||||
'integer',
|
|
||||||
'min:0',
|
|
||||||
'max:2147483647',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,11 +47,4 @@ class TaskUpdateRequest extends FormRequest
|
|||||||
|
|
||||||
return $this->boolean('is_done');
|
return $this->boolean('is_done');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEstimatedTime(): ?int
|
|
||||||
{
|
|
||||||
$input = $this->input('estimated_time');
|
|
||||||
|
|
||||||
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
<?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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -45,10 +45,11 @@ class TimeEntryAggregateRequest extends FormRequest
|
|||||||
// Filter by member ID
|
// Filter by member ID
|
||||||
'member_id' => [
|
'member_id' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Member> $builder */
|
/** @var Builder<Member> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
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
|
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
|
||||||
'member_ids' => [
|
'member_ids' => [
|
||||||
@@ -57,19 +58,21 @@ class TimeEntryAggregateRequest extends FormRequest
|
|||||||
],
|
],
|
||||||
'member_ids.*' => [
|
'member_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Member> $builder */
|
/** @var Builder<Member> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Filter by user ID
|
// Filter by user ID
|
||||||
'user_id' => [
|
'user_id' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(User::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<User> $builder */
|
/** @var Builder<User> $builder */
|
||||||
return $builder->belongsToOrganization($this->organization);
|
return $builder->belongsToOrganization($this->organization);
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
// Filter by project IDs, project IDs are OR combined
|
// Filter by project IDs, project IDs are OR combined
|
||||||
'project_ids' => [
|
'project_ids' => [
|
||||||
@@ -78,10 +81,11 @@ class TimeEntryAggregateRequest extends FormRequest
|
|||||||
],
|
],
|
||||||
'project_ids.*' => [
|
'project_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
// Filter by client IDs, client IDs are OR combined
|
// Filter by client IDs, client IDs are OR combined
|
||||||
'client_ids' => [
|
'client_ids' => [
|
||||||
@@ -90,22 +94,24 @@ class TimeEntryAggregateRequest extends FormRequest
|
|||||||
],
|
],
|
||||||
'client_ids.*' => [
|
'client_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Client> $builder */
|
/** @var Builder<Client> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
// Filter by tag IDs, tag IDs are OR combined
|
// Filter by tag IDs, tag IDs are AND combined
|
||||||
'tag_ids' => [
|
'tag_ids' => [
|
||||||
'array',
|
'array',
|
||||||
'min:1',
|
'min:1',
|
||||||
],
|
],
|
||||||
'tag_ids.*' => [
|
'tag_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Tag> $builder */
|
/** @var Builder<Tag> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
@@ -114,9 +120,10 @@ class TimeEntryAggregateRequest extends FormRequest
|
|||||||
],
|
],
|
||||||
'task_ids.*' => [
|
'task_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
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)
|
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||||
'start' => [
|
'start' => [
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\V1\TimeEntry;
|
|
||||||
|
|
||||||
use App\Models\Organization;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property Organization $organization Organization from model binding
|
|
||||||
*/
|
|
||||||
class TimeEntryDestroyMultipleRequest extends FormRequest
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the validation rules that apply to the request.
|
|
||||||
*
|
|
||||||
* @return array<string, array<string|ValidationRule>>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'ids' => [
|
|
||||||
'required',
|
|
||||||
'array',
|
|
||||||
],
|
|
||||||
'ids.*' => [
|
|
||||||
'string',
|
|
||||||
'uuid',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
<?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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Models\Client;
|
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
@@ -31,10 +30,11 @@ class TimeEntryIndexRequest extends FormRequest
|
|||||||
// Filter by member ID
|
// Filter by member ID
|
||||||
'member_id' => [
|
'member_id' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Member> $builder */
|
/** @var Builder<Member> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
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
|
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
|
||||||
'member_ids' => [
|
'member_ids' => [
|
||||||
@@ -43,22 +43,11 @@ class TimeEntryIndexRequest extends FormRequest
|
|||||||
],
|
],
|
||||||
'member_ids.*' => [
|
'member_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Member> $builder */
|
/** @var Builder<Member> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
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 project IDs, project IDs are OR combined
|
// Filter by project IDs, project IDs are OR combined
|
||||||
'project_ids' => [
|
'project_ids' => [
|
||||||
@@ -67,22 +56,24 @@ class TimeEntryIndexRequest extends FormRequest
|
|||||||
],
|
],
|
||||||
'project_ids.*' => [
|
'project_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
// Filter by tag IDs, tag IDs are OR combined
|
// Filter by tag IDs, tag IDs are AND combined
|
||||||
'tag_ids' => [
|
'tag_ids' => [
|
||||||
'array',
|
'array',
|
||||||
'min:1',
|
'min:1',
|
||||||
],
|
],
|
||||||
'tag_ids.*' => [
|
'tag_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Tag> $builder */
|
/** @var Builder<Tag> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
@@ -91,10 +82,11 @@ class TimeEntryIndexRequest extends FormRequest
|
|||||||
],
|
],
|
||||||
'task_ids.*' => [
|
'task_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Task> $builder */
|
/** @var Builder<Task> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
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)
|
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||||
'start' => [
|
'start' => [
|
||||||
@@ -125,12 +117,6 @@ class TimeEntryIndexRequest extends FormRequest
|
|||||||
'min:1',
|
'min:1',
|
||||||
'max:500',
|
'max:500',
|
||||||
],
|
],
|
||||||
// Skip the first n time entries (default: 0)
|
|
||||||
'offset' => [
|
|
||||||
'integer',
|
|
||||||
'min:0',
|
|
||||||
'max:2147483647',
|
|
||||||
],
|
|
||||||
// Filter makes sure that only time entries of a whole date are returned
|
// Filter makes sure that only time entries of a whole date are returned
|
||||||
'only_full_dates' => [
|
'only_full_dates' => [
|
||||||
'string',
|
'string',
|
||||||
@@ -143,14 +129,4 @@ class TimeEntryIndexRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return $this->input('only_full_dates', 'false') === 'true';
|
return $this->input('only_full_dates', 'false') === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getLimit(): int
|
|
||||||
{
|
|
||||||
return $this->has('limit') ? (int) $this->validated('limit', 100) : 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getOffset(): int
|
|
||||||
{
|
|
||||||
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,33 +31,36 @@ class TimeEntryStoreRequest extends FormRequest
|
|||||||
'member_id' => [
|
'member_id' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Member> $builder */
|
/** @var Builder<Member> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
'project_id' => [
|
'project_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
|
'uuid',
|
||||||
'required_with:task_id',
|
'required_with:task_id',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
'task_id' => [
|
'task_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Task> $builder */
|
/** @var Builder<Task> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Task> $builder */
|
/** @var Builder<Task> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization')
|
return $builder->whereBelongsTo($this->organization, 'organization')
|
||||||
->where('project_id', $this->input('project_id'));
|
->where('project_id', $this->input('project_id'));
|
||||||
})->uuid()->withMessage(__('validation.task_belongs_to_project')),
|
}))->withMessage(__('validation.task_belongs_to_project')),
|
||||||
],
|
],
|
||||||
// Start of time entry (ISO 8601 format, UTC timezone)
|
// Start of time entry (ISO 8601 format, UTC timezone)
|
||||||
'start' => [
|
'start' => [
|
||||||
@@ -87,10 +90,12 @@ class TimeEntryStoreRequest extends FormRequest
|
|||||||
'array',
|
'array',
|
||||||
],
|
],
|
||||||
'tags.*' => [
|
'tags.*' => [
|
||||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
'string',
|
||||||
|
'uuid',
|
||||||
|
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Tag> $builder */
|
/** @var Builder<Tag> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,34 +42,37 @@ class TimeEntryUpdateMultipleRequest extends FormRequest
|
|||||||
// ID of the organization member that the time entry should belong to
|
// ID of the organization member that the time entry should belong to
|
||||||
'changes.member_id' => [
|
'changes.member_id' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Member> $builder */
|
/** @var Builder<Member> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
// ID of the project that the time entry should belong to
|
// ID of the project that the time entry should belong to
|
||||||
'changes.project_id' => [
|
'changes.project_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
|
'uuid',
|
||||||
'required_with:task_id',
|
'required_with:task_id',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
'changes.task_id' => [
|
'changes.task_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Task> $builder */
|
/** @var Builder<Task> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Task> $builder */
|
/** @var Builder<Task> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization')
|
return $builder->whereBelongsTo($this->organization, 'organization')
|
||||||
->where('project_id', $this->input('changes.project_id'));
|
->where('project_id', $this->input('changes.project_id'));
|
||||||
})->uuid()->withMessage(__('validation.task_belongs_to_project')),
|
}))->withMessage(__('validation.task_belongs_to_project')),
|
||||||
],
|
],
|
||||||
// Whether time entry is billable
|
// Whether time entry is billable
|
||||||
'changes.billable' => [
|
'changes.billable' => [
|
||||||
@@ -88,10 +91,11 @@ class TimeEntryUpdateMultipleRequest extends FormRequest
|
|||||||
],
|
],
|
||||||
'changes.tags.*' => [
|
'changes.tags.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Tag> $builder */
|
/** @var Builder<Tag> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,34 +30,37 @@ class TimeEntryUpdateRequest extends FormRequest
|
|||||||
// ID of the organization member that the time entry should belong to
|
// ID of the organization member that the time entry should belong to
|
||||||
'member_id' => [
|
'member_id' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Member> $builder */
|
/** @var Builder<Member> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
// ID of the project that the time entry should belong to
|
// ID of the project that the time entry should belong to
|
||||||
'project_id' => [
|
'project_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
|
'uuid',
|
||||||
'required_with:task_id',
|
'required_with:task_id',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
'task_id' => [
|
'task_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Task> $builder */
|
/** @var Builder<Task> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Task> $builder */
|
/** @var Builder<Task> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization')
|
return $builder->whereBelongsTo($this->organization, 'organization')
|
||||||
->where('project_id', $this->input('project_id'));
|
->where('project_id', $this->input('project_id'));
|
||||||
})->uuid()->withMessage(__('validation.task_belongs_to_project')),
|
}))->withMessage(__('validation.task_belongs_to_project')),
|
||||||
],
|
],
|
||||||
// Start of time entry (ISO 8601 format, UTC timezone)
|
// Start of time entry (ISO 8601 format, UTC timezone)
|
||||||
'start' => [
|
'start' => [
|
||||||
@@ -86,10 +89,11 @@ class TimeEntryUpdateRequest extends FormRequest
|
|||||||
],
|
],
|
||||||
'tags.*' => [
|
'tags.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
'uuid',
|
||||||
|
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Tag> $builder */
|
/** @var Builder<Tag> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
}),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,20 +13,6 @@ use Illuminate\Http\Request;
|
|||||||
*/
|
*/
|
||||||
class OrganizationResource extends BaseResource
|
class OrganizationResource extends BaseResource
|
||||||
{
|
{
|
||||||
private bool $showBillableRate;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new resource instance.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function __construct(Organization $resource, bool $showBillableRate)
|
|
||||||
{
|
|
||||||
parent::__construct($resource);
|
|
||||||
|
|
||||||
$this->showBillableRate = $showBillableRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform the resource into an array.
|
* Transform the resource into an array.
|
||||||
*
|
*
|
||||||
@@ -42,9 +28,7 @@ class OrganizationResource extends BaseResource
|
|||||||
/** @var bool $color Personal organizations automatically created after registration */
|
/** @var bool $color Personal organizations automatically created after registration */
|
||||||
'is_personal' => $this->resource->personal_team,
|
'is_personal' => $this->resource->personal_team,
|
||||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
'billable_rate' => $this->resource->billable_rate,
|
||||||
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
|
||||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,39 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Resources\V1\Project;
|
namespace App\Http\Resources\V1\Project;
|
||||||
|
|
||||||
use App\Http\Resources\PaginatedResourceCollection;
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use App\Models\Project;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
|
||||||
|
|
||||||
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
|
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
private bool $showBillableRates;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param LengthAwarePaginator<Project> $resource
|
* The resource that this resource collects.
|
||||||
*/
|
|
||||||
public function __construct($resource, bool $showBillableRates)
|
|
||||||
{
|
|
||||||
parent::__construct($resource);
|
|
||||||
$this->showBillableRates = $showBillableRates;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function collects(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform the resource collection into an array.
|
|
||||||
*
|
*
|
||||||
* @return array<array<string, string|bool|int|null>>
|
* @var string
|
||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public $collects = ProjectResource::class;
|
||||||
{
|
|
||||||
return $this->collection->map(function (Project $project) use ($request): array {
|
|
||||||
return (new ProjectResource($project, $this->showBillableRates))
|
|
||||||
->toArray($request);
|
|
||||||
})->all();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,15 +13,6 @@ use Illuminate\Http\Request;
|
|||||||
*/
|
*/
|
||||||
class ProjectResource extends BaseResource
|
class ProjectResource extends BaseResource
|
||||||
{
|
{
|
||||||
private bool $showBillableRate;
|
|
||||||
|
|
||||||
public function __construct(Project $resource, bool $showBillableRate)
|
|
||||||
{
|
|
||||||
parent::__construct($resource);
|
|
||||||
|
|
||||||
$this->showBillableRate = $showBillableRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform the resource into an array.
|
* Transform the resource into an array.
|
||||||
*
|
*
|
||||||
@@ -41,13 +32,9 @@ class ProjectResource extends BaseResource
|
|||||||
/** @var bool $is_archived Whether the client is archived */
|
/** @var bool $is_archived Whether the client is archived */
|
||||||
'is_archived' => $this->resource->is_archived,
|
'is_archived' => $this->resource->is_archived,
|
||||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
'billable_rate' => $this->resource->billable_rate,
|
||||||
/** @var bool $is_billable Project time entries billable default */
|
/** @var bool $is_billable Project time entries billable default */
|
||||||
'is_billable' => $this->resource->is_billable,
|
'is_billable' => $this->resource->is_billable,
|
||||||
/** @var int|null $estimated_time Estimated time in seconds */
|
|
||||||
'estimated_time' => $this->resource->estimated_time,
|
|
||||||
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
|
||||||
'spent_time' => $this->resource->spent_time,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ class TaskResource extends BaseResource
|
|||||||
'is_done' => $this->resource->is_done,
|
'is_done' => $this->resource->is_done,
|
||||||
/** @var string $project_id ID of the project */
|
/** @var string $project_id ID of the project */
|
||||||
'project_id' => $this->resource->project_id,
|
'project_id' => $this->resource->project_id,
|
||||||
/** @var int|null $estimated_time Estimated time in seconds */
|
|
||||||
'estimated_time' => $this->resource->estimated_time,
|
|
||||||
/** @var int $spent_time Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
|
||||||
'spent_time' => $this->resource->spent_time,
|
|
||||||
/** @var string $created_at When the tag was created */
|
/** @var string $created_at When the tag was created */
|
||||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||||
/** @var string $updated_at When the tag was last updated */
|
/** @var string $updated_at When the tag was last updated */
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\TimeEntry;
|
namespace App\Http\Resources\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Http\Resources\PaginatedResourceCollection;
|
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection
|
class TimeEntryCollection extends ResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\Project;
|
|
||||||
use Exception;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
class RecalculateSpentTimeForProject implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public Project $project;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new job instance.
|
|
||||||
*/
|
|
||||||
public function __construct(Project $project)
|
|
||||||
{
|
|
||||||
$this->project = $project;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the job.
|
|
||||||
*
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function handle(): void
|
|
||||||
{
|
|
||||||
$this->project->setComputedAttributeValue('spent_time');
|
|
||||||
if ($this->project->isDirty()) {
|
|
||||||
$this->project->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\Task;
|
|
||||||
use Exception;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
class RecalculateSpentTimeForTask implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public Task $task;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new job instance.
|
|
||||||
*/
|
|
||||||
public function __construct(Task $task)
|
|
||||||
{
|
|
||||||
$this->task = $task;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the job.
|
|
||||||
*
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function handle(): void
|
|
||||||
{
|
|
||||||
$this->task->setComputedAttributeValue('spent_time');
|
|
||||||
if ($this->task->isDirty()) {
|
|
||||||
$this->task->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $currency
|
* @property string $currency
|
||||||
* @property int|null $billable_rate
|
* @property int|null $billable_rate
|
||||||
* @property string $user_id
|
* @property string $user_id
|
||||||
* @property bool $employees_can_see_billable_rates
|
|
||||||
* @property User $owner
|
* @property User $owner
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
@@ -59,7 +58,6 @@ class Organization extends JetstreamTeam implements AuditableContract
|
|||||||
'name' => 'string',
|
'name' => 'string',
|
||||||
'personal_team' => 'boolean',
|
'personal_team' => 'boolean',
|
||||||
'currency' => 'string',
|
'currency' => 'string',
|
||||||
'employees_can_see_billable_rates' => 'boolean',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
|
||||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,8 +27,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property bool $is_public
|
* @property bool $is_public
|
||||||
* @property bool $is_billable
|
* @property bool $is_billable
|
||||||
* @property-read bool $is_archived
|
* @property-read bool $is_archived
|
||||||
* @property int|null $estimated_time
|
|
||||||
* @property int $spent_time
|
|
||||||
* @property Carbon|null $archived_at
|
* @property Carbon|null $archived_at
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
@@ -44,7 +40,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
*/
|
*/
|
||||||
class Project extends Model implements AuditableContract
|
class Project extends Model implements AuditableContract
|
||||||
{
|
{
|
||||||
use ComputedAttributes;
|
|
||||||
use CustomAuditable;
|
use CustomAuditable;
|
||||||
|
|
||||||
/** @use HasFactory<ProjectFactory> */
|
/** @use HasFactory<ProjectFactory> */
|
||||||
@@ -61,8 +56,6 @@ class Project extends Model implements AuditableContract
|
|||||||
'name' => 'string',
|
'name' => 'string',
|
||||||
'color' => 'string',
|
'color' => 'string',
|
||||||
'archived_at' => 'datetime',
|
'archived_at' => 'datetime',
|
||||||
'estimated_time' => 'integer',
|
|
||||||
'spent_time' => 'integer',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,68 +67,6 @@ class Project extends Model implements AuditableContract
|
|||||||
'is_billable' => false,
|
'is_billable' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that are computed. (f.e. for performance reasons)
|
|
||||||
* These attributes can be regenerated at any time.
|
|
||||||
*
|
|
||||||
* @var string[]
|
|
||||||
*/
|
|
||||||
protected array $computed = [
|
|
||||||
'spent_time',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attributes to exclude from the Audit.
|
|
||||||
*
|
|
||||||
* @var array<string>
|
|
||||||
*/
|
|
||||||
protected array $auditExclude = [
|
|
||||||
'spent_time',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function getSpentTimeComputed(): ?int
|
|
||||||
{
|
|
||||||
if ($this->hasAttribute('spent_time_computed')) {
|
|
||||||
return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed'];
|
|
||||||
} else {
|
|
||||||
/** @var object{ spent_time: string } $result */
|
|
||||||
$result = $this->timeEntries()
|
|
||||||
->whereNotNull('end')
|
|
||||||
->selectRaw('sum(extract(epoch from ("end" - start))) as spent_time')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return (int) $result->spent_time;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
|
|
||||||
*
|
|
||||||
* @param Builder<Project> $builder
|
|
||||||
* @param array<string> $attributes Attributes that will be generated.
|
|
||||||
* @return Builder<Project>
|
|
||||||
*/
|
|
||||||
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
|
|
||||||
{
|
|
||||||
if (in_array('spent_time', $attributes, true)) {
|
|
||||||
$builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from ("end" - start))'), 'sum');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $builder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
|
|
||||||
*
|
|
||||||
* @param Builder<Project> $builder
|
|
||||||
* @param array<string> $attributes Attributes that will be validated.
|
|
||||||
* @return Builder<Project>
|
|
||||||
*/
|
|
||||||
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
|
|
||||||
{
|
|
||||||
return $this->scopeComputedAttributesGenerate($builder, $attributes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return BelongsTo<Organization, Project>
|
* @return BelongsTo<Organization, Project>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,14 +7,11 @@ namespace App\Models;
|
|||||||
use App\Models\Concerns\CustomAuditable;
|
use App\Models\Concerns\CustomAuditable;
|
||||||
use App\Models\Concerns\HasUuids;
|
use App\Models\Concerns\HasUuids;
|
||||||
use Database\Factories\TagFactory;
|
use Database\Factories\TagFactory;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||||
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
|
|
||||||
use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property string $id
|
* @property string $id
|
||||||
@@ -22,7 +19,6 @@ use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
|
|||||||
* @property string $organization_id
|
* @property string $organization_id
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property-read Collection<TimeEntry> $timeEntries
|
|
||||||
* @property-read Organization $organization
|
* @property-read Organization $organization
|
||||||
*
|
*
|
||||||
* @method static TagFactory factory()
|
* @method static TagFactory factory()
|
||||||
@@ -34,7 +30,6 @@ class Tag extends Model implements AuditableContract
|
|||||||
/** @use HasFactory<TagFactory> */
|
/** @use HasFactory<TagFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use HasJsonRelationships;
|
|
||||||
use HasUuids;
|
use HasUuids;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,14 +48,4 @@ class Tag extends Model implements AuditableContract
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Organization::class, 'organization_id');
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.
|
|
||||||
*
|
|
||||||
* @return HasManyJson<TimeEntry, $this>
|
|
||||||
*/
|
|
||||||
public function timeEntries(): HasManyJson
|
|
||||||
{
|
|
||||||
return $this->hasManyJson(TimeEntry::class, 'tags');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
|
||||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,8 +23,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $project_id
|
* @property string $project_id
|
||||||
* @property string $organization_id
|
* @property string $organization_id
|
||||||
* @property Carbon|null $done_at
|
* @property Carbon|null $done_at
|
||||||
* @property int|null $estimated_time
|
|
||||||
* @property int $spent_time
|
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property-read Project $project
|
* @property-read Project $project
|
||||||
@@ -38,7 +34,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
*/
|
*/
|
||||||
class Task extends Model implements AuditableContract
|
class Task extends Model implements AuditableContract
|
||||||
{
|
{
|
||||||
use ComputedAttributes;
|
|
||||||
use CustomAuditable;
|
use CustomAuditable;
|
||||||
|
|
||||||
/** @use HasFactory<TaskFactory> */
|
/** @use HasFactory<TaskFactory> */
|
||||||
@@ -53,72 +48,9 @@ class Task extends Model implements AuditableContract
|
|||||||
*/
|
*/
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'name' => 'string',
|
'name' => 'string',
|
||||||
'estimated_time' => 'integer',
|
|
||||||
'done_at' => 'datetime',
|
'done_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that are computed. (f.e. for performance reasons)
|
|
||||||
* These attributes can be regenerated at any time.
|
|
||||||
*
|
|
||||||
* @var string[]
|
|
||||||
*/
|
|
||||||
protected array $computed = [
|
|
||||||
'spent_time',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attributes to exclude from the Audit.
|
|
||||||
*
|
|
||||||
* @var array<string>
|
|
||||||
*/
|
|
||||||
protected array $auditExclude = [
|
|
||||||
'spent_time',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function getSpentTimeComputed(): ?int
|
|
||||||
{
|
|
||||||
if ($this->hasAttribute('spent_time_computed')) {
|
|
||||||
return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed'];
|
|
||||||
} else {
|
|
||||||
/** @var object{ spent_time: string } $result */
|
|
||||||
$result = $this->timeEntries()
|
|
||||||
->whereNotNull('end')
|
|
||||||
->selectRaw('sum(extract(epoch from ("end" - start))) as spent_time')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return (int) $result->spent_time;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
|
|
||||||
*
|
|
||||||
* @param Builder<Task> $builder
|
|
||||||
* @param array<string> $attributes Attributes that will be generated.
|
|
||||||
* @return Builder<Task>
|
|
||||||
*/
|
|
||||||
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
|
|
||||||
{
|
|
||||||
if (in_array('spent_time', $attributes, true)) {
|
|
||||||
$builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from ("end" - start))'), 'sum');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $builder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
|
|
||||||
*
|
|
||||||
* @param Builder<Task> $builder
|
|
||||||
* @param array<string> $attributes Attributes that will be validated.
|
|
||||||
* @return Builder<Task>
|
|
||||||
*/
|
|
||||||
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
|
|
||||||
{
|
|
||||||
return $this->scopeComputedAttributesGenerate($builder, $attributes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return BelongsTo<Project, Task>
|
* @return BelongsTo<Project, Task>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,16 +10,12 @@ use App\Service\BillableRateService;
|
|||||||
use Carbon\CarbonInterval;
|
use Carbon\CarbonInterval;
|
||||||
use Database\Factories\TimeEntryFactory;
|
use Database\Factories\TimeEntryFactory;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
||||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||||
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
|
|
||||||
use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property string $id
|
* @property string $id
|
||||||
@@ -45,7 +41,6 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
|
|||||||
* @property-read Client|null $client
|
* @property-read Client|null $client
|
||||||
* @property string|null $task_id
|
* @property string|null $task_id
|
||||||
* @property-read Task|null $task
|
* @property-read Task|null $task
|
||||||
* @property-read Collection<Tag> $tagsRelation
|
|
||||||
*
|
*
|
||||||
* @method Builder<TimeEntry> hasTag(Tag $tag)
|
* @method Builder<TimeEntry> hasTag(Tag $tag)
|
||||||
* @method static TimeEntryFactory factory()
|
* @method static TimeEntryFactory factory()
|
||||||
@@ -58,7 +53,6 @@ class TimeEntry extends Model implements AuditableContract
|
|||||||
/** @use HasFactory<TimeEntryFactory> */
|
/** @use HasFactory<TimeEntryFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use HasJsonRelationships;
|
|
||||||
use HasUuids;
|
use HasUuids;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,16 +79,6 @@ class TimeEntry extends Model implements AuditableContract
|
|||||||
*/
|
*/
|
||||||
protected array $computed = [
|
protected array $computed = [
|
||||||
'billable_rate',
|
'billable_rate',
|
||||||
'client_id',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attributes to exclude from the Audit.
|
|
||||||
*
|
|
||||||
* @var array<string>
|
|
||||||
*/
|
|
||||||
protected array $auditExclude = [
|
|
||||||
'billable_rate',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getBillableRateComputed(): ?int
|
public function getBillableRateComputed(): ?int
|
||||||
@@ -102,44 +86,6 @@ class TimeEntry extends Model implements AuditableContract
|
|||||||
return app(BillableRateService::class)->getBillableRateForTimeEntry($this);
|
return app(BillableRateService::class)->getBillableRateForTimeEntry($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getClientIdComputed(): ?string
|
|
||||||
{
|
|
||||||
return $this->project_id === null ? null : $this->project->client_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
|
|
||||||
*
|
|
||||||
* @param Builder<TimeEntry> $builder
|
|
||||||
* @param array<string> $attributes Attributes that will be generated.
|
|
||||||
* @return Builder<TimeEntry>
|
|
||||||
*/
|
|
||||||
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
|
|
||||||
{
|
|
||||||
if (in_array('client_id', $attributes, true)) {
|
|
||||||
$builder->with([
|
|
||||||
'project' => function (Relation $builder): void {
|
|
||||||
/** @var Builder<Project> $builder */
|
|
||||||
$builder->select('id', 'client_id');
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $builder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
|
|
||||||
*
|
|
||||||
* @param Builder<TimeEntry> $builder
|
|
||||||
* @param array<string> $attributes Attributes that will be validated.
|
|
||||||
* @return Builder<TimeEntry>
|
|
||||||
*/
|
|
||||||
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
|
|
||||||
{
|
|
||||||
return $this->scopeComputedAttributesGenerate($builder, $attributes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDuration(): ?CarbonInterval
|
public function getDuration(): ?CarbonInterval
|
||||||
{
|
{
|
||||||
return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end);
|
return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end);
|
||||||
@@ -202,14 +148,4 @@ class TimeEntry extends Model implements AuditableContract
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Client::class, 'client_id');
|
return $this->belongsTo(Client::class, 'client_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.
|
|
||||||
*
|
|
||||||
* @return BelongsToJson<Tag, $this>
|
|
||||||
*/
|
|
||||||
public function tagsRelation(): BelongsToJson
|
|
||||||
{
|
|
||||||
return $this->belongsToJson(Tag::class, 'tags');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Laravel\Jetstream\HasProfilePhoto;
|
use Laravel\Jetstream\HasProfilePhoto;
|
||||||
use Laravel\Jetstream\HasTeams;
|
use Laravel\Jetstream\HasTeams;
|
||||||
use Laravel\Passport\AuthCode;
|
|
||||||
use Laravel\Passport\HasApiTokens;
|
use Laravel\Passport\HasApiTokens;
|
||||||
use Laravel\Passport\Token;
|
|
||||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -180,22 +178,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
return $this->hasMany(ProjectMember::class, 'user_id');
|
return $this->hasMany(ProjectMember::class, 'user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<Token>
|
|
||||||
*/
|
|
||||||
public function accessTokens(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Token::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<AuthCode>
|
|
||||||
*/
|
|
||||||
public function authCodes(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(AuthCode::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Builder<User> $builder
|
* @param Builder<User> $builder
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use Filament\Tables\Table;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@@ -80,20 +81,19 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Scramble
|
// Scramble
|
||||||
Scramble::extendOpenApi(function (OpenApi $openApi): void {
|
Scramble::extendOpenApi(function (OpenApi $openApi) {
|
||||||
$openApi->secure(
|
$openApi->secure(
|
||||||
SecurityScheme::oauth2()
|
SecurityScheme::oauth2()
|
||||||
->flow('authorizationCode', function (OAuthFlow $flow): void {
|
->flow('authorizationCode', function (OAuthFlow $flow) {
|
||||||
$flow
|
$flow
|
||||||
->authorizationUrl('https://solidtime.test/oauth/authorize');
|
->authorizationUrl('https://solidtime.test/oauth/authorize');
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config('app.force_https', false)) {
|
if (config('app.force_https', false) || App::isProduction()) {
|
||||||
URL::forceScheme('https');
|
URL::forceScheme('https');
|
||||||
request()->server->set('HTTPS', 'on');
|
request()->server->set('HTTPS', request()->header('X-Forwarded-Proto', 'https') === 'https' ? 'on' : 'off');
|
||||||
request()->headers->set('X-Forwarded-Proto', 'https');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
|
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Providers\Filament;
|
namespace App\Providers\Filament;
|
||||||
|
|
||||||
use App\Filament\Widgets\ActiveUserOverview;
|
use App\Filament\Widgets\ActiveUserOverview;
|
||||||
use App\Filament\Widgets\ServerOverview;
|
|
||||||
use App\Filament\Widgets\TimeEntriesCreated;
|
use App\Filament\Widgets\TimeEntriesCreated;
|
||||||
use App\Filament\Widgets\TimeEntriesImported;
|
use App\Filament\Widgets\TimeEntriesImported;
|
||||||
use App\Filament\Widgets\UserRegistrations;
|
use App\Filament\Widgets\UserRegistrations;
|
||||||
@@ -45,13 +44,11 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
])
|
])
|
||||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||||
->widgets([
|
->widgets([
|
||||||
ServerOverview::class,
|
|
||||||
ActiveUserOverview::class,
|
ActiveUserOverview::class,
|
||||||
UserRegistrations::class,
|
UserRegistrations::class,
|
||||||
TimeEntriesCreated::class,
|
TimeEntriesCreated::class,
|
||||||
TimeEntriesImported::class,
|
TimeEntriesImported::class,
|
||||||
])
|
])
|
||||||
->viteTheme('resources/css/filament/admin/theme.css')
|
|
||||||
->plugins([
|
->plugins([
|
||||||
EnvironmentIndicatorPlugin::make()
|
EnvironmentIndicatorPlugin::make()
|
||||||
->color(fn () => match (App::environment()) {
|
->color(fn () => match (App::environment()) {
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ class RouteServiceProvider extends ServiceProvider
|
|||||||
: Limit::perMinute(60)->by($request->ip());
|
: Limit::perMinute(60)->by($request->ip());
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->routes(function (): void {
|
$this->routes(function () {
|
||||||
Route::middleware('health-check')
|
Route::middleware('health-check')
|
||||||
->group(function (): void {
|
->group(function () {
|
||||||
Route::get('health-check/up', [HealthCheckController::class, 'up']);
|
Route::get('health-check/up', [HealthCheckController::class, 'up']);
|
||||||
Route::get('health-check/debug', [HealthCheckController::class, 'debug']);
|
Route::get('health-check/debug', [HealthCheckController::class, 'debug']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Service;
|
|
||||||
|
|
||||||
use App\Models\Audit;
|
|
||||||
use App\Models\Client;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\ProjectMember;
|
|
||||||
use App\Models\Task;
|
|
||||||
use App\Models\TimeEntry;
|
|
||||||
use App\Models\User;
|
|
||||||
use Exception;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Log;
|
|
||||||
|
|
||||||
class ApiService
|
|
||||||
{
|
|
||||||
private const string API_URL = 'https://app.solidtime.io/api/v1';
|
|
||||||
|
|
||||||
public function checkForUpdate(): ?string
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$response = Http::asJson()
|
|
||||||
->timeout(3)
|
|
||||||
->connectTimeout(2)
|
|
||||||
->post(self::API_URL.'/ping/version', [
|
|
||||||
'version' => config('app.version'),
|
|
||||||
'build' => config('app.build'),
|
|
||||||
'url' => config('app.url'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->status() === 200 && isset($response->json()['version']) && is_string($response->json()['version'])) {
|
|
||||||
return $response->json()['version'];
|
|
||||||
} else {
|
|
||||||
Log::warning('Failed to check for update', [
|
|
||||||
'status' => $response->status(),
|
|
||||||
'body' => $response->body(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::warning('Failed to check for update', [
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function telemetry(): bool
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$response = Http::asJson()
|
|
||||||
->timeout(3)
|
|
||||||
->connectTimeout(2)
|
|
||||||
->post(self::API_URL.'/ping/telemetry', [
|
|
||||||
'version' => config('app.version'),
|
|
||||||
'build' => config('app.build'),
|
|
||||||
'url' => config('app.url'),
|
|
||||||
// telemetry data
|
|
||||||
'user_count' => User::count(),
|
|
||||||
'organization_count' => Organization::count(),
|
|
||||||
'audit_count' => Audit::count(),
|
|
||||||
'project_count' => Project::count(),
|
|
||||||
'project_member_count' => ProjectMember::count(),
|
|
||||||
'client_count' => Client::count(),
|
|
||||||
'task_count' => Task::count(),
|
|
||||||
'time_entry_count' => TimeEntry::count(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->status() === 200) {
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
Log::warning('Failed send telemetry data', [
|
|
||||||
'status' => $response->status(),
|
|
||||||
'body' => $response->body(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
Log::warning('Failed send telemetry data', [
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,9 +28,9 @@ class BillableRateService
|
|||||||
->where('billable', '=', true)
|
->where('billable', '=', true)
|
||||||
->where('organization_id', '=', $project->organization_id)
|
->where('organization_id', '=', $project->organization_id)
|
||||||
->whereBelongsTo($project, 'project')
|
->whereBelongsTo($project, 'project')
|
||||||
->whereDoesntHave('member', function (Builder $query) use ($project): void {
|
->whereDoesntHave('member', function (Builder $query) use ($project) {
|
||||||
/** @var Builder<Member> $query */
|
/** @var Builder<Member> $query */
|
||||||
$query->whereHas('projectMembers', function (Builder $query) use ($project): void {
|
$query->whereHas('projectMembers', function (Builder $query) use ($project) {
|
||||||
/** @var Builder<ProjectMember> $query */
|
/** @var Builder<ProjectMember> $query */
|
||||||
$query->whereBelongsTo($project, 'project')
|
$query->whereBelongsTo($project, 'project')
|
||||||
->whereNotNull('billable_rate');
|
->whereNotNull('billable_rate');
|
||||||
@@ -62,7 +62,7 @@ class BillableRateService
|
|||||||
TimeEntry::query()
|
TimeEntry::query()
|
||||||
->where('billable', '=', true)
|
->where('billable', '=', true)
|
||||||
->where('organization_id', '=', $organization->getKey())
|
->where('organization_id', '=', $organization->getKey())
|
||||||
->whereDoesntHave('member', function (Builder $builder): void {
|
->whereDoesntHave('member', function (Builder $builder) {
|
||||||
/** @var Builder<Member> $builder */
|
/** @var Builder<Member> $builder */
|
||||||
$builder->whereNotNull('billable_rate');
|
$builder->whereNotNull('billable_rate');
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class BillingContract
|
|||||||
*/
|
*/
|
||||||
public function hasSubscription(Organization $organization): bool
|
public function hasSubscription(Organization $organization): bool
|
||||||
{
|
{
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -47,24 +47,22 @@ class DashboardService
|
|||||||
{
|
{
|
||||||
$result = [];
|
$result = [];
|
||||||
$windowSize = 24 / $windows;
|
$windowSize = 24 / $windows;
|
||||||
$end = Carbon::now($timeZone)->startOfDay()->addDay()->subHours(3)->utc()->toDateTimeString();
|
$end = Carbon::now($timeZone)->endOfDay()->subHours(3)->utc()->toDateTimeString();
|
||||||
$start = Carbon::now($timeZone)->subDays($days)->startOfDay()->utc()->toDateTimeString();
|
$start = Carbon::now($timeZone)->subDays($days)->startOfDay()->utc()->toDateTimeString();
|
||||||
|
|
||||||
$date = Carbon::now($timeZone)->startOfDay();
|
$date = Carbon::now($timeZone)->startOfDay();
|
||||||
$dateUtc = Carbon::now($timeZone)->startOfDay()->utc();
|
|
||||||
for ($i = 0; $i < $days; $i++) {
|
for ($i = 0; $i < $days; $i++) {
|
||||||
$dateString = $date->format('Y-m-d');
|
$dateString = $date->format('Y-m-d');
|
||||||
$tempDate = $dateUtc->copy();
|
$tempDate = $date->copy();
|
||||||
$start = $tempDate->copy()->utc()->toDateTimeString();
|
$start = $tempDate->copy()->utc()->toDateTimeString();
|
||||||
$tempWindows = [];
|
$tempWindows = [];
|
||||||
for ($j = 0; $j < $windows; $j++) {
|
for ($j = 0; $j < $windows; $j++) {
|
||||||
$tempWindow = $tempDate->toDateTimeString();
|
$tempWindow = $tempDate->utc()->toDateTimeString();
|
||||||
$tempWindows[] = $tempWindow;
|
$tempWindows[] = $tempWindow;
|
||||||
$tempDate->addHours($windowSize);
|
$tempDate->addHours($windowSize);
|
||||||
}
|
}
|
||||||
$result[$dateString] = $tempWindows;
|
$result[$dateString] = $tempWindows;
|
||||||
$date->subDay();
|
$date->subDay();
|
||||||
$dateUtc->subDay();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class DeletionService
|
|||||||
public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void
|
public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void
|
||||||
{
|
{
|
||||||
if ($inTransaction) {
|
if ($inTransaction) {
|
||||||
DB::transaction(function () use ($organization): void {
|
DB::transaction(function () use ($organization) {
|
||||||
$this->deleteOrganization($organization, false);
|
$this->deleteOrganization($organization, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ class DeletionService
|
|||||||
public function deleteUser(User $user, bool $inTransaction = true): void
|
public function deleteUser(User $user, bool $inTransaction = true): void
|
||||||
{
|
{
|
||||||
if ($inTransaction) {
|
if ($inTransaction) {
|
||||||
DB::transaction(function () use ($user): void {
|
DB::transaction(function () use ($user) {
|
||||||
$this->deleteUser($user, false);
|
$this->deleteUser($user, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,7 +144,6 @@ class DeletionService
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
foreach ($members as $member) {
|
foreach ($members as $member) {
|
||||||
/** @var Member $member */
|
|
||||||
if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) {
|
if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) {
|
||||||
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
}
|
}
|
||||||
@@ -155,13 +154,10 @@ class DeletionService
|
|||||||
if ($member->role === Role::Owner->value) {
|
if ($member->role === Role::Owner->value) {
|
||||||
$this->deleteOrganization($member->organization, false, $user);
|
$this->deleteOrganization($member->organization, false, $user);
|
||||||
} else {
|
} else {
|
||||||
$this->memberService->makeMemberToPlaceholder($member, false);
|
$this->memberService->makeMemberToPlaceholder($member);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->accessTokens()->delete();
|
|
||||||
$user->authCodes()->delete();
|
|
||||||
|
|
||||||
// Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last
|
// Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last
|
||||||
$user->deleteProfilePhoto();
|
$user->deleteProfilePhoto();
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ class ExportService
|
|||||||
// Organizations
|
// Organizations
|
||||||
try {
|
try {
|
||||||
$writer = Writer::createFromPath($temporaryDirectory->path('organizations.csv'), 'w+');
|
$writer = Writer::createFromPath($temporaryDirectory->path('organizations.csv'), 'w+');
|
||||||
$writer->setDelimiter(',');
|
|
||||||
$writer->setEnclosure('"');
|
|
||||||
$writer->setEscape('');
|
|
||||||
$writer->insertOne([
|
$writer->insertOne([
|
||||||
'id',
|
'id',
|
||||||
'name',
|
'name',
|
||||||
@@ -69,9 +66,6 @@ class ExportService
|
|||||||
|
|
||||||
// Organization invitations
|
// Organization invitations
|
||||||
$writer = Writer::createFromPath($temporaryDirectory->path('organization_invitations.csv'), 'w+');
|
$writer = Writer::createFromPath($temporaryDirectory->path('organization_invitations.csv'), 'w+');
|
||||||
$writer->setDelimiter(',');
|
|
||||||
$writer->setEnclosure('"');
|
|
||||||
$writer->setEscape('');
|
|
||||||
$writer->insertOne([
|
$writer->insertOne([
|
||||||
'id',
|
'id',
|
||||||
'email',
|
'email',
|
||||||
@@ -97,9 +91,6 @@ class ExportService
|
|||||||
|
|
||||||
// Time entries
|
// Time entries
|
||||||
$writer = Writer::createFromPath($temporaryDirectory->path('time_entries.csv'), 'w+');
|
$writer = Writer::createFromPath($temporaryDirectory->path('time_entries.csv'), 'w+');
|
||||||
$writer->setDelimiter(',');
|
|
||||||
$writer->setEnclosure('"');
|
|
||||||
$writer->setEscape('');
|
|
||||||
$writer->insertOne([
|
$writer->insertOne([
|
||||||
'id',
|
'id',
|
||||||
'description',
|
'description',
|
||||||
@@ -148,9 +139,6 @@ class ExportService
|
|||||||
|
|
||||||
// Clients
|
// Clients
|
||||||
$writer = Writer::createFromPath($temporaryDirectory->path('clients.csv'), 'w+');
|
$writer = Writer::createFromPath($temporaryDirectory->path('clients.csv'), 'w+');
|
||||||
$writer->setDelimiter(',');
|
|
||||||
$writer->setEnclosure('"');
|
|
||||||
$writer->setEscape('');
|
|
||||||
$writer->insertOne([
|
$writer->insertOne([
|
||||||
'id',
|
'id',
|
||||||
'name',
|
'name',
|
||||||
@@ -176,9 +164,6 @@ class ExportService
|
|||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
$writer = Writer::createFromPath($temporaryDirectory->path('projects.csv'), 'w+');
|
$writer = Writer::createFromPath($temporaryDirectory->path('projects.csv'), 'w+');
|
||||||
$writer->setDelimiter(',');
|
|
||||||
$writer->setEnclosure('"');
|
|
||||||
$writer->setEscape('');
|
|
||||||
$writer->insertOne([
|
$writer->insertOne([
|
||||||
'id',
|
'id',
|
||||||
'name',
|
'name',
|
||||||
@@ -214,9 +199,6 @@ class ExportService
|
|||||||
|
|
||||||
// Project members
|
// Project members
|
||||||
$writer = Writer::createFromPath($temporaryDirectory->path('project_members.csv'), 'w+');
|
$writer = Writer::createFromPath($temporaryDirectory->path('project_members.csv'), 'w+');
|
||||||
$writer->setDelimiter(',');
|
|
||||||
$writer->setEnclosure('"');
|
|
||||||
$writer->setEscape('');
|
|
||||||
$writer->insertOne([
|
$writer->insertOne([
|
||||||
'id',
|
'id',
|
||||||
'billable_rate',
|
'billable_rate',
|
||||||
@@ -244,9 +226,6 @@ class ExportService
|
|||||||
|
|
||||||
// Members
|
// Members
|
||||||
$writer = Writer::createFromPath($temporaryDirectory->path('members.csv'), 'w+');
|
$writer = Writer::createFromPath($temporaryDirectory->path('members.csv'), 'w+');
|
||||||
$writer->setDelimiter(',');
|
|
||||||
$writer->setEnclosure('"');
|
|
||||||
$writer->setEscape('');
|
|
||||||
$writer->insertOne([
|
$writer->insertOne([
|
||||||
'id',
|
'id',
|
||||||
'user_id',
|
'user_id',
|
||||||
@@ -281,9 +260,6 @@ class ExportService
|
|||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
$writer = Writer::createFromPath($temporaryDirectory->path('tasks.csv'), 'w+');
|
$writer = Writer::createFromPath($temporaryDirectory->path('tasks.csv'), 'w+');
|
||||||
$writer->setDelimiter(',');
|
|
||||||
$writer->setEnclosure('"');
|
|
||||||
$writer->setEscape('');
|
|
||||||
$writer->insertOne([
|
$writer->insertOne([
|
||||||
'id',
|
'id',
|
||||||
'name',
|
'name',
|
||||||
@@ -311,9 +287,6 @@ class ExportService
|
|||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
$writer = Writer::createFromPath($temporaryDirectory->path('tags.csv'), 'w+');
|
$writer = Writer::createFromPath($temporaryDirectory->path('tags.csv'), 'w+');
|
||||||
$writer->setDelimiter(',');
|
|
||||||
$writer->setEnclosure('"');
|
|
||||||
$writer->setEscape('');
|
|
||||||
$writer->insertOne([
|
$writer->insertOne([
|
||||||
'id',
|
'id',
|
||||||
'name',
|
'name',
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class ImportService
|
|||||||
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
|
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
|
||||||
|
|
||||||
if ($lock->get()) {
|
if ($lock->get()) {
|
||||||
DB::transaction(function () use (&$importer, &$data, &$timezone): void {
|
DB::transaction(function () use (&$importer, &$data, &$timezone) {
|
||||||
$importer->importData($data, $timezone);
|
$importer->importData($data, $timezone);
|
||||||
});
|
});
|
||||||
$lock->release();
|
$lock->release();
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
$reader = Reader::createFromString($data);
|
$reader = Reader::createFromString($data);
|
||||||
$reader->setHeaderOffset(0);
|
$reader->setHeaderOffset(0);
|
||||||
$reader->setDelimiter(',');
|
$reader->setDelimiter(',');
|
||||||
$reader->setEnclosure('"');
|
|
||||||
$reader->setEscape('');
|
|
||||||
$header = $reader->getHeader();
|
$header = $reader->getHeader();
|
||||||
$this->validateHeader($header);
|
$this->validateHeader($header);
|
||||||
$records = $reader->getRecords();
|
$records = $reader->getRecords();
|
||||||
|
|||||||
@@ -112,9 +112,8 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
'billable_rate' => [
|
'billable_rate' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'max:2147483647',
|
|
||||||
],
|
],
|
||||||
], beforeSave: function (Project $project): void {
|
], beforeSave: function (Project $project) {
|
||||||
if ($project->billable_rate === 0) {
|
if ($project->billable_rate === 0) {
|
||||||
$project->billable_rate = null;
|
$project->billable_rate = null;
|
||||||
}
|
}
|
||||||
@@ -126,9 +125,8 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
'billable_rate' => [
|
'billable_rate' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'max:2147483647',
|
|
||||||
],
|
],
|
||||||
], beforeSave: function (ProjectMember $projectMember): void {
|
], beforeSave: function (ProjectMember $projectMember) {
|
||||||
if ($projectMember->billable_rate === 0) {
|
if ($projectMember->billable_rate === 0) {
|
||||||
$projectMember->billable_rate = null;
|
$projectMember->billable_rate = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$clientsReader = Reader::createFromPath($temporaryDirectory->path('clients.csv'));
|
$clientsReader = Reader::createFromPath($temporaryDirectory->path('clients.csv'));
|
||||||
$clientsReader->setHeaderOffset(0);
|
$clientsReader->setHeaderOffset(0);
|
||||||
$clientsReader->setDelimiter(',');
|
$clientsReader->setDelimiter(',');
|
||||||
$clientsReader->setEnclosure('"');
|
|
||||||
$clientsReader->setEscape('');
|
|
||||||
|
|
||||||
if (! file_exists($temporaryDirectory->path('members.csv'))) {
|
if (! file_exists($temporaryDirectory->path('members.csv'))) {
|
||||||
throw new ImportException('File "members.csv" missing in ZIP');
|
throw new ImportException('File "members.csv" missing in ZIP');
|
||||||
@@ -69,8 +67,6 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$membersReader = Reader::createFromPath($temporaryDirectory->path('members.csv'));
|
$membersReader = Reader::createFromPath($temporaryDirectory->path('members.csv'));
|
||||||
$membersReader->setHeaderOffset(0);
|
$membersReader->setHeaderOffset(0);
|
||||||
$membersReader->setDelimiter(',');
|
$membersReader->setDelimiter(',');
|
||||||
$membersReader->setEnclosure('"');
|
|
||||||
$membersReader->setEscape('');
|
|
||||||
|
|
||||||
if (! file_exists($temporaryDirectory->path('organization_invitations.csv'))) {
|
if (! file_exists($temporaryDirectory->path('organization_invitations.csv'))) {
|
||||||
throw new ImportException('File "organization_invitations.csv" missing in ZIP');
|
throw new ImportException('File "organization_invitations.csv" missing in ZIP');
|
||||||
@@ -78,8 +74,6 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$organizationInvitationsReader = Reader::createFromPath($temporaryDirectory->path('organization_invitations.csv'));
|
$organizationInvitationsReader = Reader::createFromPath($temporaryDirectory->path('organization_invitations.csv'));
|
||||||
$organizationInvitationsReader->setHeaderOffset(0);
|
$organizationInvitationsReader->setHeaderOffset(0);
|
||||||
$organizationInvitationsReader->setDelimiter(',');
|
$organizationInvitationsReader->setDelimiter(',');
|
||||||
$organizationInvitationsReader->setEnclosure('"');
|
|
||||||
$organizationInvitationsReader->setEscape('');
|
|
||||||
|
|
||||||
if (! file_exists($temporaryDirectory->path('project_members.csv'))) {
|
if (! file_exists($temporaryDirectory->path('project_members.csv'))) {
|
||||||
throw new ImportException('File "project_members.csv" missing in ZIP');
|
throw new ImportException('File "project_members.csv" missing in ZIP');
|
||||||
@@ -87,8 +81,6 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$projectMembersReader = Reader::createFromPath($temporaryDirectory->path('project_members.csv'));
|
$projectMembersReader = Reader::createFromPath($temporaryDirectory->path('project_members.csv'));
|
||||||
$projectMembersReader->setHeaderOffset(0);
|
$projectMembersReader->setHeaderOffset(0);
|
||||||
$projectMembersReader->setDelimiter(',');
|
$projectMembersReader->setDelimiter(',');
|
||||||
$projectMembersReader->setEnclosure('"');
|
|
||||||
$projectMembersReader->setEscape('');
|
|
||||||
|
|
||||||
if (! file_exists($temporaryDirectory->path('projects.csv'))) {
|
if (! file_exists($temporaryDirectory->path('projects.csv'))) {
|
||||||
throw new ImportException('File "projects.csv" missing in ZIP');
|
throw new ImportException('File "projects.csv" missing in ZIP');
|
||||||
@@ -96,8 +88,6 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$projectsReader = Reader::createFromPath($temporaryDirectory->path('projects.csv'));
|
$projectsReader = Reader::createFromPath($temporaryDirectory->path('projects.csv'));
|
||||||
$projectsReader->setHeaderOffset(0);
|
$projectsReader->setHeaderOffset(0);
|
||||||
$projectsReader->setDelimiter(',');
|
$projectsReader->setDelimiter(',');
|
||||||
$projectsReader->setEnclosure('"');
|
|
||||||
$projectsReader->setEscape('');
|
|
||||||
|
|
||||||
if (! file_exists($temporaryDirectory->path('tags.csv'))) {
|
if (! file_exists($temporaryDirectory->path('tags.csv'))) {
|
||||||
throw new ImportException('File "tags.csv" missing in ZIP');
|
throw new ImportException('File "tags.csv" missing in ZIP');
|
||||||
@@ -105,8 +95,6 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$tagsReader = Reader::createFromPath($temporaryDirectory->path('tags.csv'));
|
$tagsReader = Reader::createFromPath($temporaryDirectory->path('tags.csv'));
|
||||||
$tagsReader->setHeaderOffset(0);
|
$tagsReader->setHeaderOffset(0);
|
||||||
$tagsReader->setDelimiter(',');
|
$tagsReader->setDelimiter(',');
|
||||||
$tagsReader->setEnclosure('"');
|
|
||||||
$tagsReader->setEscape('');
|
|
||||||
|
|
||||||
if (! file_exists($temporaryDirectory->path('tasks.csv'))) {
|
if (! file_exists($temporaryDirectory->path('tasks.csv'))) {
|
||||||
throw new ImportException('File "tasks.csv" missing in ZIP');
|
throw new ImportException('File "tasks.csv" missing in ZIP');
|
||||||
@@ -114,8 +102,6 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$tasksReader = Reader::createFromPath($temporaryDirectory->path('tasks.csv'));
|
$tasksReader = Reader::createFromPath($temporaryDirectory->path('tasks.csv'));
|
||||||
$tasksReader->setHeaderOffset(0);
|
$tasksReader->setHeaderOffset(0);
|
||||||
$tasksReader->setDelimiter(',');
|
$tasksReader->setDelimiter(',');
|
||||||
$tasksReader->setEnclosure('"');
|
|
||||||
$tasksReader->setEscape('');
|
|
||||||
|
|
||||||
if (! file_exists($temporaryDirectory->path('time_entries.csv'))) {
|
if (! file_exists($temporaryDirectory->path('time_entries.csv'))) {
|
||||||
throw new ImportException('File "time_entries.csv" missing in ZIP');
|
throw new ImportException('File "time_entries.csv" missing in ZIP');
|
||||||
@@ -123,8 +109,6 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$timeEntriesReader = Reader::createFromPath($temporaryDirectory->path('time_entries.csv'));
|
$timeEntriesReader = Reader::createFromPath($temporaryDirectory->path('time_entries.csv'));
|
||||||
$timeEntriesReader->setHeaderOffset(0);
|
$timeEntriesReader->setHeaderOffset(0);
|
||||||
$timeEntriesReader->setDelimiter(',');
|
$timeEntriesReader->setDelimiter(',');
|
||||||
$timeEntriesReader->setEnclosure('"');
|
|
||||||
$timeEntriesReader->setEscape('');
|
|
||||||
|
|
||||||
foreach ($clientsReader as $client) {
|
foreach ($clientsReader as $client) {
|
||||||
$this->clientImportHelper->getKey([
|
$this->clientImportHelper->getKey([
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
|||||||
$reader = Reader::createFromString($data);
|
$reader = Reader::createFromString($data);
|
||||||
$reader->setHeaderOffset(0);
|
$reader->setHeaderOffset(0);
|
||||||
$reader->setDelimiter(',');
|
$reader->setDelimiter(',');
|
||||||
$reader->setEnclosure('"');
|
|
||||||
$reader->setEscape('');
|
|
||||||
$header = $reader->getHeader();
|
$header = $reader->getHeader();
|
||||||
$this->validateHeader($header);
|
$this->validateHeader($header);
|
||||||
$records = $reader->getRecords();
|
$records = $reader->getRecords();
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<?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, bool $makeSureUserHasAtLeastOneOrganization = true): void
|
public function makeMemberToPlaceholder(Member $member): void
|
||||||
{
|
{
|
||||||
$user = $member->user;
|
$user = $member->user;
|
||||||
$placeholderUser = $user->replicate();
|
$placeholderUser = $user->replicate();
|
||||||
@@ -56,8 +56,6 @@ class MemberService
|
|||||||
$member->save();
|
$member->save();
|
||||||
|
|
||||||
$this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
|
$this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
|
||||||
if ($makeSureUserHasAtLeastOneOrganization) {
|
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
||||||
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<?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(', '),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
<?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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
<?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,11 +7,7 @@ namespace App\Service;
|
|||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Models\Client;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Task;
|
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
|
||||||
use Carbon\CarbonTimeZone;
|
use Carbon\CarbonTimeZone;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -139,118 +135,6 @@ class TimeEntryAggregationService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<TimeEntry> $timeEntriesQuery
|
|
||||||
* @return array{
|
|
||||||
* grouped_type: string|null,
|
|
||||||
* grouped_data: null|array<array{
|
|
||||||
* key: string|null,
|
|
||||||
* description: string|null,
|
|
||||||
* seconds: int,
|
|
||||||
* cost: int,
|
|
||||||
* grouped_type: string|null,
|
|
||||||
* grouped_data: null|array<array{
|
|
||||||
* key: string|null,
|
|
||||||
* description: string|null,
|
|
||||||
* seconds: int,
|
|
||||||
* cost: int,
|
|
||||||
* grouped_type: null,
|
|
||||||
* grouped_data: null
|
|
||||||
* }>
|
|
||||||
* }>,
|
|
||||||
* seconds: int,
|
|
||||||
* cost: int
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
|
|
||||||
{
|
|
||||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end);
|
|
||||||
|
|
||||||
$keysGroup1 = [];
|
|
||||||
$keysGroup2 = [];
|
|
||||||
|
|
||||||
if ($aggregatedTimeEntries['grouped_data'] !== null) {
|
|
||||||
foreach ($aggregatedTimeEntries['grouped_data'] as $group1) {
|
|
||||||
$keysGroup1[] = $group1['key'];
|
|
||||||
if ($group1['grouped_data'] !== null) {
|
|
||||||
foreach ($group1['grouped_data'] as $group2) {
|
|
||||||
$keysGroup2[] = $group2['key'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$descriptionMapGroup1 = $group1Type !== null ? $this->loadDescriptionMap($keysGroup1, $group1Type) : [];
|
|
||||||
$descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptionMap($keysGroup2, $group2Type) : [];
|
|
||||||
|
|
||||||
if ($aggregatedTimeEntries['grouped_data'] !== null) {
|
|
||||||
foreach ($aggregatedTimeEntries['grouped_data'] as $keyGroup1 => $group1) {
|
|
||||||
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']] ?? null) : null;
|
|
||||||
if ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] !== null) {
|
|
||||||
foreach ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] as $keyGroup2 => $group2) {
|
|
||||||
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']] ?? null) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array{
|
|
||||||
* grouped_type: string|null,
|
|
||||||
* grouped_data: null|array<array{
|
|
||||||
* key: string|null,
|
|
||||||
* description: string|null,
|
|
||||||
* seconds: int,
|
|
||||||
* cost: int,
|
|
||||||
* grouped_type: string|null,
|
|
||||||
* grouped_data: null|array<array{
|
|
||||||
* key: string|null,
|
|
||||||
* description: string|null,
|
|
||||||
* seconds: int,
|
|
||||||
* cost: int,
|
|
||||||
* grouped_type: null,
|
|
||||||
* grouped_data: null
|
|
||||||
* }>
|
|
||||||
* }>,
|
|
||||||
* seconds: int,
|
|
||||||
* cost: int
|
|
||||||
* } $aggregatedTimeEntries
|
|
||||||
*/
|
|
||||||
|
|
||||||
return $aggregatedTimeEntries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $keys
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function loadDescriptionMap(array $keys, TimeEntryAggregationType $type): array
|
|
||||||
{
|
|
||||||
if ($type === TimeEntryAggregationType::Client) {
|
|
||||||
return Client::query()
|
|
||||||
->whereIn('id', $keys)
|
|
||||||
->pluck('name', 'id')
|
|
||||||
->toArray();
|
|
||||||
} elseif ($type === TimeEntryAggregationType::User) {
|
|
||||||
return User::query()
|
|
||||||
->whereIn('id', $keys)
|
|
||||||
->pluck('name', 'id')
|
|
||||||
->toArray();
|
|
||||||
} elseif ($type === TimeEntryAggregationType::Project) {
|
|
||||||
return Project::query()
|
|
||||||
->whereIn('id', $keys)
|
|
||||||
->pluck('name', 'id')
|
|
||||||
->toArray();
|
|
||||||
} elseif ($type === TimeEntryAggregationType::Task) {
|
|
||||||
return Task::query()
|
|
||||||
->whereIn('id', $keys)
|
|
||||||
->pluck('name', 'id')
|
|
||||||
->toArray();
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<array{
|
* @param array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
|
|||||||
@@ -133,11 +133,7 @@ class TimeEntryFilter
|
|||||||
if ($tagIds === null) {
|
if ($tagIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$this->builder->where(function (Builder $builder) use ($tagIds): void {
|
$this->builder->whereJsonContains('tags', $tagIds);
|
||||||
foreach ($tagIds as $tagId) {
|
|
||||||
$builder->orWhereJsonContains('tags', $tagId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,16 @@
|
|||||||
"name": "solidtime-io/solidtime",
|
"name": "solidtime-io/solidtime",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "An open-source time-tracking app",
|
"description": "An open-source time-tracking app",
|
||||||
|
"version": "0.0.1",
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "8.3.*",
|
"php": "8.3.*",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"brick/money": "^0.9.0",
|
"brick/money": "^0.9.0",
|
||||||
"datomatic/laravel-enum-helper": "^1.1",
|
|
||||||
"dedoc/scramble": "dev-main",
|
"dedoc/scramble": "dev-main",
|
||||||
"filament/filament": "^3.2",
|
"filament/filament": "^3.2",
|
||||||
"flowframe/laravel-trend": "^0.2.0",
|
"flowframe/laravel-trend": "^0.2.0",
|
||||||
"gotenberg/gotenberg-php": "^2.8",
|
|
||||||
"guzzlehttp/guzzle": "^7.2",
|
"guzzlehttp/guzzle": "^7.2",
|
||||||
"inertiajs/inertia-laravel": "^1.0",
|
"inertiajs/inertia-laravel": "^1.0",
|
||||||
"korridor/laravel-computed-attributes": "^3.1",
|
"korridor/laravel-computed-attributes": "^3.1",
|
||||||
@@ -22,18 +21,15 @@
|
|||||||
"laravel/octane": "^2.3",
|
"laravel/octane": "^2.3",
|
||||||
"laravel/passport": "^12.0",
|
"laravel/passport": "^12.0",
|
||||||
"laravel/tinker": "^2.8",
|
"laravel/tinker": "^2.8",
|
||||||
"league/csv": "^9.16.0",
|
|
||||||
"league/flysystem-aws-s3-v3": "^3.0",
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
"maatwebsite/excel": "^3.1",
|
|
||||||
"novadaemon/filament-pretty-json": "^2.2",
|
"novadaemon/filament-pretty-json": "^2.2",
|
||||||
"nwidart/laravel-modules": "^11.0.11",
|
"nwidart/laravel-modules": "^11.0.11",
|
||||||
"owen-it/laravel-auditing": "^13.6",
|
"owen-it/laravel-auditing": "^13.6",
|
||||||
"pxlrbt/filament-environment-indicator": "^2.0",
|
"pxlrbt/filament-environment-indicator": "^2.0",
|
||||||
"spatie/temporary-directory": "^2.2",
|
"spatie/temporary-directory": "^2.2",
|
||||||
"staudenmeir/eloquent-json-relations": "^1.1",
|
|
||||||
"stechstudio/filament-impersonate": "^3.8",
|
"stechstudio/filament-impersonate": "^3.8",
|
||||||
"tightenco/ziggy": "^2.1.0",
|
"tightenco/ziggy": "^2.1.0",
|
||||||
"tpetry/laravel-postgresql-enhanced": "^2.0.0",
|
"tpetry/laravel-postgresql-enhanced": "^1.0.0",
|
||||||
"wikimedia/composer-merge-plugin": "^2.1.0"
|
"wikimedia/composer-merge-plugin": "^2.1.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
2119
composer.lock
generated
2119
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -18,11 +18,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'name' => 'solidtime',
|
'name' => env('APP_NAME', 'solidtime'),
|
||||||
|
|
||||||
'version' => env('APP_VERSION'),
|
|
||||||
|
|
||||||
'build' => env('APP_BUILD'),
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
382
config/excel.php
382
config/excel.php
@@ -1,382 +0,0 @@
|
|||||||
<?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,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
@@ -6,7 +6,5 @@ return [
|
|||||||
|
|
||||||
'tasks' => [
|
'tasks' => [
|
||||||
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
|
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
|
||||||
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
|
|
||||||
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
<?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'),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
@@ -85,7 +85,7 @@ class MemberFactory extends Factory
|
|||||||
|
|
||||||
public function attachToOrganization(Organization $organization, array $pivot = []): static
|
public function attachToOrganization(Organization $organization, array $pivot = []): static
|
||||||
{
|
{
|
||||||
return $this->afterCreating(function (User $user) use ($organization, $pivot): void {
|
return $this->afterCreating(function (User $user) use ($organization, $pivot) {
|
||||||
$user->organizations()->attach($organization, $pivot);
|
$user->organizations()->attach($organization, $pivot);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ class OrganizationFactory extends Factory
|
|||||||
'billable_rate' => null,
|
'billable_rate' => null,
|
||||||
'user_id' => User::factory(),
|
'user_id' => User::factory(),
|
||||||
'personal_team' => true,
|
'personal_team' => true,
|
||||||
'employees_can_see_billable_rates' => false,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Models\Project;
|
|||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Service\ColorService;
|
use App\Service\ColorService;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<Project>
|
* @extends Factory<Project>
|
||||||
@@ -34,34 +33,15 @@ class ProjectFactory extends Factory
|
|||||||
'archived_at' => null,
|
'archived_at' => null,
|
||||||
'client_id' => null,
|
'client_id' => null,
|
||||||
'organization_id' => Organization::factory(),
|
'organization_id' => Organization::factory(),
|
||||||
'estimated_time' => null,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function withEstimatedTime(): self
|
public function billable(): self
|
||||||
{
|
{
|
||||||
return $this->state(function (array $attributes): array {
|
return $this->state(function (array $attributes): array {
|
||||||
return [
|
|
||||||
'estimated_time' => $this->faker->randomNumber(3),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function billable(?int $billableRate = null): self
|
|
||||||
{
|
|
||||||
return $this->state(function (array $attributes) use ($billableRate): array {
|
|
||||||
return [
|
return [
|
||||||
'is_billable' => true,
|
'is_billable' => true,
|
||||||
'billable_rate' => $billableRate === null ? $this->faker->numberBetween(50, 1000) * 100 : $billableRate,
|
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createdAt(Carbon $createdAt): self
|
|
||||||
{
|
|
||||||
return $this->state(function (array $attributes) use ($createdAt): array {
|
|
||||||
return [
|
|
||||||
'created_at' => $createdAt,
|
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user