mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-21 20:23:19 +01:00
Compare commits
41 Commits
feature/up
...
feature/ph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6e7cbcec5 | ||
|
|
c8a300e1b6 | ||
|
|
eed654b9c8 | ||
|
|
68833a5125 | ||
|
|
735f3b8d39 | ||
|
|
9caf911795 | ||
|
|
c55afd234e | ||
|
|
20cc5f9e5b | ||
|
|
2df7a2845e | ||
|
|
510cb10cb9 | ||
|
|
b46f015754 | ||
|
|
fc8985f513 | ||
|
|
812b3f2f54 | ||
|
|
4b25b2adfa | ||
|
|
9557296eb0 | ||
|
|
c69edc81be | ||
|
|
902270a189 | ||
|
|
f60c9ca457 | ||
|
|
1c41432389 | ||
|
|
3197c8c7a0 | ||
|
|
b8761dffce | ||
|
|
ba2b37f264 | ||
|
|
9e6013eaf1 | ||
|
|
11dd4e174e | ||
|
|
b73cdc4bb5 | ||
|
|
e82c601449 | ||
|
|
f65a1532ad | ||
|
|
b42e84294c | ||
|
|
1863953a23 | ||
|
|
ce9413a55e | ||
|
|
b08364770c | ||
|
|
26926d3b2c | ||
|
|
fd008dd4a8 | ||
|
|
9a845bad8f | ||
|
|
b9e3f55537 | ||
|
|
0b2a417788 | ||
|
|
65525791a6 | ||
|
|
0c58896b00 | ||
|
|
6301b0f4a1 | ||
|
|
4c993c8eb2 | ||
|
|
ae6937d012 |
14
.github/workflows/generate-api-docs.yml
vendored
14
.github/workflows/generate-api-docs.yml
vendored
@@ -3,6 +3,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -57,10 +58,9 @@ jobs:
|
|||||||
name: api-docs.json
|
name: api-docs.json
|
||||||
path: build/api-docs.json
|
path: build/api-docs.json
|
||||||
|
|
||||||
- name: "Download Fastfront CLI"
|
- name: "Deploy with FastFront"
|
||||||
run: curl https://fastfront-cli.s3.fr-par.scw.cloud/fastfront-cli.phar -o fastfront-cli.phar
|
uses: fastfront-io/deploy-action@v0.0.1
|
||||||
|
with:
|
||||||
- name: "Deploy with Fastfront"
|
folder: './build'
|
||||||
run: php fastfront-cli.phar deploy 9beab6cf-f459-446b-85f1-38ec007cf457 ./build
|
environment: ${{ vars.FASTFRONT_API_DOCS_STAGE }}
|
||||||
env:
|
api_key: ${{ secrets.FASTFRONT_API_DOCS_STAGE_KEY }}
|
||||||
FASTFRONT_API_KEY: ${{ secrets.FASTFRONT_API_DOCS_API_KEY }}
|
|
||||||
|
|||||||
86
.oss-scanner/Dockerfile
Normal file
86
.oss-scanner/Dockerfile
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Build environment for Anthropic's OSS Scanner (https://github.com/anthropics/oss-scanner).
|
||||||
|
# The scanner builds this image with the repository root as the build context and then audits it without network
|
||||||
|
# access, so everything needed to run the app and its test suite (PHP + Composer deps, Node deps + built frontend,
|
||||||
|
# a local PostgreSQL and a local Gotenberg for PDF rendering) is installed here.
|
||||||
|
#
|
||||||
|
# The base image is the FrankenPHP image the production image (docker/prod/Dockerfile) is built on: in production the
|
||||||
|
# app runs as long-lived Laravel Octane workers in FrankenPHP worker mode, not as one PHP process per request.
|
||||||
|
#
|
||||||
|
# Inside the finished image:
|
||||||
|
# .oss-scanner/start-postgres.sh start the local PostgreSQL server (required for tests and the app)
|
||||||
|
# .oss-scanner/start-gotenberg.sh start the local Gotenberg server (required for PDF exports)
|
||||||
|
# php artisan test run the PHPUnit suite (needs PostgreSQL and Gotenberg running)
|
||||||
|
# .oss-scanner/start-octane.sh run the app like production on http://127.0.0.1:8000 (starts everything above)
|
||||||
|
|
||||||
|
ARG FRANKENPHP_VERSION=1.11
|
||||||
|
ARG PHP_VERSION=8.3
|
||||||
|
|
||||||
|
FROM node:20-trixie-slim AS node
|
||||||
|
|
||||||
|
FROM gotenberg/gotenberg:8 AS gotenberg
|
||||||
|
|
||||||
|
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
COMPOSER_ALLOW_SUPERUSER=1 \
|
||||||
|
COMPOSER_NO_INTERACTION=1 \
|
||||||
|
OCTANE_SERVER=frankenphp \
|
||||||
|
TZ=UTC
|
||||||
|
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||||
|
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
||||||
|
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||||
|
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||||
|
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||||
|
|
||||||
|
# PostgreSQL 17 (Debian trixie default) is one of the versions solidtime is tested against in CI.
|
||||||
|
# chromium, qpdf and exiftool are what Gotenberg needs for HTML to PDF rendering.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git unzip curl ca-certificates postgresql postgresql-client \
|
||||||
|
chromium fonts-liberation qpdf libimage-exiftool-perl \
|
||||||
|
&& install-php-extensions pdo_pgsql pgsql intl gd zip bcmath exif pcntl sockets soap apcu \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& echo "memory_limit=2G" > "$PHP_INI_DIR/conf.d/99-oss-scanner.ini"
|
||||||
|
|
||||||
|
# Gotenberg 8 (the PDF renderer, same image as in CI). solidtime only uses its Chromium HTML to PDF route, so LibreOffice
|
||||||
|
# and pdftk are not installed: Gotenberg only checks at startup that their binaries exist, start-gotenberg.sh disables
|
||||||
|
# the LibreOffice routes and the Chromium route does not use pdftk.
|
||||||
|
COPY --from=gotenberg /usr/bin/gotenberg /usr/local/bin/gotenberg
|
||||||
|
COPY --from=gotenberg /usr/bin/pdfcpu /usr/local/bin/pdfcpu
|
||||||
|
COPY --from=gotenberg /opt/gotenberg /opt/gotenberg
|
||||||
|
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium \
|
||||||
|
CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data \
|
||||||
|
GOTENBERG_VERSIONS_DIR_PATH=/opt/gotenberg/versions \
|
||||||
|
QPDF_BIN_PATH=/usr/bin/qpdf \
|
||||||
|
EXIFTOOL_BIN_PATH=/usr/bin/exiftool \
|
||||||
|
PDFCPU_BIN_PATH=/usr/local/bin/pdfcpu \
|
||||||
|
LIBREOFFICE_BIN_PATH=/bin/false \
|
||||||
|
UNOCONVERTER_BIN_PATH=/bin/false \
|
||||||
|
PDFTK_BIN_PATH=/bin/false
|
||||||
|
|
||||||
|
# Database matching .env.ci: user root / password root, database laravel.
|
||||||
|
RUN pg_ctlcluster 17 main start \
|
||||||
|
&& runuser -u postgres -- psql -c "CREATE ROLE root WITH LOGIN SUPERUSER PASSWORD 'root';" \
|
||||||
|
&& runuser -u postgres -- createdb -O root laravel \
|
||||||
|
&& pg_ctlcluster 17 main stop
|
||||||
|
|
||||||
|
# scanner contract: the checkout lives inside the image, at /src
|
||||||
|
COPY . /src
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
RUN composer install --prefer-dist \
|
||||||
|
&& npm ci \
|
||||||
|
&& npm run build
|
||||||
|
|
||||||
|
RUN cp .env.ci .env \
|
||||||
|
&& php artisan key:generate \
|
||||||
|
&& php artisan passport:keys --force \
|
||||||
|
&& php artisan octane:install --server=frankenphp --no-interaction \
|
||||||
|
&& chmod +x .oss-scanner/*.sh
|
||||||
|
|
||||||
|
# Run the test suite so the image is known to work, but do not fail the build on test failures.
|
||||||
|
RUN .oss-scanner/start-postgres.sh \
|
||||||
|
&& .oss-scanner/start-gotenberg.sh \
|
||||||
|
&& (php artisan test || echo "WARNING: PHPUnit reported failures") \
|
||||||
|
&& kill "$(cat /tmp/gotenberg.pid)" \
|
||||||
|
&& pg_ctlcluster 17 main stop
|
||||||
18
.oss-scanner/Dockerfile.dockerignore
Normal file
18
.oss-scanner/Dockerfile.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Used instead of the root .dockerignore when building .oss-scanner/Dockerfile. The root one is tailored to the
|
||||||
|
# production image and excludes tests, phpunit.xml etc., which the scanner needs.
|
||||||
|
node_modules
|
||||||
|
extensions/*/node_modules
|
||||||
|
vendor
|
||||||
|
.env
|
||||||
|
public/build
|
||||||
|
public/hot
|
||||||
|
storage/*.key
|
||||||
|
storage/logs/*
|
||||||
|
coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
.phpunit.cache
|
||||||
|
.phpunit.result.cache
|
||||||
|
auth.json
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
16
.oss-scanner/start-gotenberg.sh
Executable file
16
.oss-scanner/start-gotenberg.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start the local Gotenberg server (PDF rendering) on 127.0.0.1:3000, matching GOTENBERG_URL in .env.ci.
|
||||||
|
# Only the Chromium routes are needed by solidtime, see .oss-scanner/Dockerfile. Stop: kill "$(cat /tmp/gotenberg.pid)"
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! curl -fs http://127.0.0.1:3000/health >/dev/null; then
|
||||||
|
nohup gotenberg \
|
||||||
|
--api-port=3000 \
|
||||||
|
--libreoffice-disable-routes \
|
||||||
|
--libreoffice-auto-start=false \
|
||||||
|
--log-level=warn \
|
||||||
|
>/tmp/gotenberg.log 2>&1 &
|
||||||
|
echo $! >/tmp/gotenberg.pid
|
||||||
|
until curl -fs http://127.0.0.1:3000/health >/dev/null; do sleep 0.5; done
|
||||||
|
fi
|
||||||
|
echo "Gotenberg is running on http://127.0.0.1:3000 (log: /tmp/gotenberg.log)"
|
||||||
37
.oss-scanner/start-octane.sh
Executable file
37
.oss-scanner/start-octane.sh
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the app the way production does: Laravel Octane on FrankenPHP in worker mode, with the production Caddyfile.
|
||||||
|
# A worker boots the app once and then serves many requests, so state kept in memory (static properties, singletons,
|
||||||
|
# changed config, ...) survives from one request to the next. With a single worker, consecutive requests always hit the
|
||||||
|
# same worker, so such leaks between requests (e.g. between users of different organizations) reproduce reliably.
|
||||||
|
#
|
||||||
|
# Usage: .oss-scanner/start-octane.sh (OCTANE_WORKERS and OCTANE_MAX_REQUESTS can be overridden)
|
||||||
|
# Stop: php artisan octane:stop
|
||||||
|
# After changing PHP code, run `php artisan octane:reload`: workers keep the code they booted with.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
.oss-scanner/start-postgres.sh
|
||||||
|
.oss-scanner/start-gotenberg.sh
|
||||||
|
|
||||||
|
php artisan migrate --force
|
||||||
|
if [ "$(PGPASSWORD=root psql -h 127.0.0.1 -U root -d laravel -tAc 'SELECT count(*) FROM users')" = "0" ]; then
|
||||||
|
php artisan db:seed --force
|
||||||
|
fi
|
||||||
|
php artisan optimize:clear >/dev/null
|
||||||
|
|
||||||
|
nohup php artisan octane:frankenphp \
|
||||||
|
--host=127.0.0.1 \
|
||||||
|
--port=8000 \
|
||||||
|
--workers="${OCTANE_WORKERS:-1}" \
|
||||||
|
--max-requests="${OCTANE_MAX_REQUESTS:-10000}" \
|
||||||
|
--caddyfile=docker/prod/deployment/octane/FrankenPHP/Caddyfile \
|
||||||
|
>/tmp/octane.log 2>&1 &
|
||||||
|
|
||||||
|
until curl -fs -o /dev/null http://127.0.0.1:8000/login; do
|
||||||
|
if ! kill -0 $! 2>/dev/null; then
|
||||||
|
cat /tmp/octane.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
echo "solidtime is running on http://127.0.0.1:8000 with Octane/FrankenPHP (log: /tmp/octane.log)"
|
||||||
7
.oss-scanner/start-postgres.sh
Executable file
7
.oss-scanner/start-postgres.sh
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start the local PostgreSQL server used by the test suite and the app (see .oss-scanner/Dockerfile).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
pg_ctlcluster 17 main start 2>/dev/null || true
|
||||||
|
until pg_isready -h 127.0.0.1 -p 5432 -q; do sleep 0.5; done
|
||||||
|
echo "PostgreSQL is running on 127.0.0.1:5432 (user root, password root, database laravel)"
|
||||||
122
.oss-scanner/threat_model.md
Normal file
122
.oss-scanner/threat_model.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Threat model
|
||||||
|
|
||||||
|
## What this project does
|
||||||
|
|
||||||
|
solidtime is an open-source, multi-tenant time tracking web application (Laravel backend, Vue 3 + Inertia frontend,
|
||||||
|
PostgreSQL). It runs as a hosted SaaS (solidtime.io) and is self-hosted by many organisations. Users belong to one or
|
||||||
|
more **organizations**; inside an organization each member has a role: `owner`, `admin`, `manager`, `employee` or
|
||||||
|
`placeholder` (an imported, non-login member). What each role may do is defined in `app/Service/PermissionStore.php`.
|
||||||
|
|
||||||
|
The most important security property is **isolation**: a user must never read or modify data of an organization they
|
||||||
|
are not a member of, and within an organization a member must not exceed the permissions of their role (e.g. an
|
||||||
|
employee must not see other members' time entries, billable rates, or manage members, unless the organization settings
|
||||||
|
explicitly allow it).
|
||||||
|
|
||||||
|
## Trust boundaries
|
||||||
|
|
||||||
|
- **Super admins are fully trusted.** They are the instance operators, configured via the `SUPER_ADMINS` env
|
||||||
|
variable, and have access to the Filament admin panel (`app/Filament`), which can view and change data of every
|
||||||
|
organization and impersonate users. Anything a super admin can do through the panel (including XSS, SQL injection,
|
||||||
|
SSRF or file access that is only reachable from the panel) is not a vulnerability.
|
||||||
|
- What **is** in scope: a user who is not a super admin reaching the admin panel, or any of its actions, at all.
|
||||||
|
- Operators of a self-hosted instance (shell, database, environment, filesystem access) are trusted.
|
||||||
|
- Everyone else, including organization owners and admins when acting outside their own organization, is untrusted.
|
||||||
|
|
||||||
|
## Runtime: long-lived Octane workers
|
||||||
|
|
||||||
|
In production (the hosted SaaS and the official Docker image, `docker/prod/`) the app does **not** run as one PHP
|
||||||
|
process per request. It runs on Laravel Octane with FrankenPHP in worker mode: each worker boots the application once
|
||||||
|
and then serves many requests from different users and organizations. Anything kept in memory survives from one request
|
||||||
|
to the next unless Octane resets it (`config/octane.php` lists what is reset). This includes static properties and
|
||||||
|
static caches, container bindings registered with `singleton()` instead of `scoped()` (see
|
||||||
|
`app/Providers/AppServiceProvider.php`), objects captured by those singletons, runtime `config()` / locale / timezone
|
||||||
|
changes, macros and event listeners registered during a request, and state in third-party packages.
|
||||||
|
|
||||||
|
Request A leaving state behind that request B (another user, possibly of another organization) then sees or is
|
||||||
|
affected by is in scope, rated by its impact like any other issue (see severity below). The PHPUnit suite cannot
|
||||||
|
show this class of bug, because it boots a fresh application for every test. It has to be reproduced over HTTP against
|
||||||
|
the Octane server, see "How to exercise it".
|
||||||
|
|
||||||
|
## Where untrusted input enters
|
||||||
|
|
||||||
|
All authenticated users, including employees of any organization and anyone who self-registers (registration is open
|
||||||
|
by default), are untrusted.
|
||||||
|
|
||||||
|
- **JSON API** `routes/api.php` (`/api/v1/...`), authenticated via Passport (session cookie or personal access token).
|
||||||
|
Most routes are scoped by `{organization}` and authorised in the controllers / form requests.
|
||||||
|
- **Public, unauthenticated** endpoints: `GET /api/v1/public/reports` (shared reports, accessed by a secret), login,
|
||||||
|
registration, password reset, email verification, organization invitation acceptance (`routes/web.php`).
|
||||||
|
- **Web / Inertia routes** `routes/web.php` and Fortify/Jetstream actions in `app/Actions`.
|
||||||
|
- **Imports** (`app/Service/Import/Importers`): user-uploaded CSV and ZIP files from Toggl, Clockify, Harvest,
|
||||||
|
generic CSV and solidtime's own export format. ZIP handling is in `ZipImportHelper.php`.
|
||||||
|
- **Exports / reports** (`app/Service/Export`, `app/Service/ReportExport`): CSV/XLSX/ODS and PDF. PDFs are rendered by
|
||||||
|
sending HTML to a Gotenberg (headless Chromium) service, so user-controlled content in that HTML matters.
|
||||||
|
- **OAuth** (Passport) authorization and token endpoints.
|
||||||
|
- **Filament admin panel** (`app/Filament`): only its access control is in scope (see Trust boundaries).
|
||||||
|
|
||||||
|
## Components that matter most / least
|
||||||
|
|
||||||
|
Most important: organization scoping and role checks in the API controllers, form requests (`app/Http/Requests`),
|
||||||
|
`PermissionStore`, public report sharing, invitations and member management (role changes, ownership transfer, member
|
||||||
|
merge), authentication flows (Fortify, 2FA, email change, API tokens), import parsing.
|
||||||
|
|
||||||
|
Less important / out of scope:
|
||||||
|
- `extensions/` is empty in this repository (proprietary modules are not part of the open-source code).
|
||||||
|
- `docker/`, `k8s/`, `e2e/`, `playwright/`, `docs/` and developer tooling.
|
||||||
|
- Third-party dependencies in `vendor/` and `node_modules/`, unless solidtime uses them in an unsafe way.
|
||||||
|
|
||||||
|
## How to exercise it
|
||||||
|
|
||||||
|
- `.oss-scanner/start-postgres.sh` starts the local PostgreSQL server (user `root`, password `root`, db `laravel`).
|
||||||
|
- `.oss-scanner/start-gotenberg.sh` starts the local Gotenberg server (PDF rendering via headless Chromium) on
|
||||||
|
http://127.0.0.1:3000, so the PDF export code path, including what Chromium does with the rendered HTML, can be
|
||||||
|
exercised.
|
||||||
|
- `php artisan test` runs the PHPUnit suite (start PostgreSQL and Gotenberg first); all tests are expected to pass. Endpoint tests in `tests/Unit/Endpoint/Api/V1/` show how to create users,
|
||||||
|
organizations and members with factories and call the API with a given role; they are the quickest way to write a
|
||||||
|
reproducer. Example: `php artisan test --filter=TimeEntryEndpointTest`.
|
||||||
|
- To run the app like production: `.oss-scanner/start-octane.sh` (http://127.0.0.1:8000). It starts PostgreSQL and
|
||||||
|
Gotenberg, migrates (and seeds an empty database, see `database/seeders/DatabaseSeeder.php` for the users) and runs
|
||||||
|
Octane/FrankenPHP with the production Caddyfile and a **single worker**, so consecutive requests always hit the same
|
||||||
|
worker and leaks between requests reproduce reliably. Workers keep the code they booted with: after changing PHP
|
||||||
|
code run `php artisan octane:reload`. Stop it with `php artisan octane:stop`. Note that the test suite and the app
|
||||||
|
share the same database, so `php artisan test` wipes the app's data.
|
||||||
|
- A reproducer for a leak between requests is a script that sends request A (e.g. as a member of organization X) and
|
||||||
|
then request B (as a user of organization Y) to the running Octane server and shows that B observes A's state. API
|
||||||
|
requests can be authenticated with a personal access token, e.g. created with
|
||||||
|
`php artisan tinker --execute="echo App\Models\User::where('email', '...')->first()->createToken('t')->accessToken;"`.
|
||||||
|
- There is no network: mail delivery is not available (mail uses the `array` driver in tests), and remote resources
|
||||||
|
referenced by PDF templates (e.g. fonts from fonts.bunny.net) fail to load, so PDFs fall back to local fonts.
|
||||||
|
|
||||||
|
## How we rate severity
|
||||||
|
|
||||||
|
- **Critical**: unauthenticated access to other users' data or accounts; authentication bypass; remote code execution;
|
||||||
|
SQL injection reachable by any registered user; reading or writing data of an organization the attacker is not a
|
||||||
|
member of, including through state leaking between requests in an Octane worker.
|
||||||
|
- **High**: privilege escalation within an organization (e.g. employee to admin/owner, or performing admin-only
|
||||||
|
actions); access to data the role must not see (other members' time entries, billable rates, member emails) when
|
||||||
|
the organization settings do not allow it; stored XSS that executes in another user's session; SSRF via PDF
|
||||||
|
rendering or imports; account takeover requiring user interaction.
|
||||||
|
- **Medium**: information disclosure with limited impact, CSRF on state-changing endpoints, issues requiring an
|
||||||
|
unusual but realistic configuration, denial of service by a single authenticated request (e.g. pathological
|
||||||
|
import file).
|
||||||
|
- **Low**: everything else with real security impact.
|
||||||
|
|
||||||
|
## Anything to leave alone
|
||||||
|
|
||||||
|
Please do not report (see also `SECURITY.md`):
|
||||||
|
- Theoretical findings without a working reproducer.
|
||||||
|
- Missing or weak security headers in isolation; TLS / mail DNS configuration.
|
||||||
|
- Self-XSS; CSRF on non-state-changing endpoints (logout, theme).
|
||||||
|
- CSV / spreadsheet formula injection in exports.
|
||||||
|
- Owners or admins acting destructively within their own organization.
|
||||||
|
- Anything requiring direct DB, shell or filesystem access on a self-hosted instance.
|
||||||
|
- Anything that requires being a super admin, including issues inside the Filament admin panel.
|
||||||
|
- Missing OAuth scope enforcement (not implemented yet).
|
||||||
|
- Rate-limit tuning and generic DoS through volume of requests.
|
||||||
|
|
||||||
|
## Reports and patches
|
||||||
|
|
||||||
|
Please include the affected endpoint or code path, the attacker's role and the victim, a PHPUnit test (in the style of
|
||||||
|
`tests/Unit/Endpoint/Api/V1/`) that reproduces the issue (for leaks between requests: a script against
|
||||||
|
`.oss-scanner/start-octane.sh` instead), and a minimal patch that follows the existing patterns
|
||||||
|
(authorisation in form requests/controllers via `PermissionStore`).
|
||||||
@@ -6,7 +6,19 @@ Contributions are greatly apprecited, please make sure to read the rules and vis
|
|||||||
|
|
||||||
### Issues for Bugs, Discussions for Feature requests
|
### Issues for Bugs, Discussions for Feature requests
|
||||||
|
|
||||||
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancement are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
|
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancements are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
|
||||||
|
|
||||||
|
### Do not use AI-generated text in issues, discussions and PR descriptions
|
||||||
|
|
||||||
|
We are human, so please write in natural language in your issues, discussions, and PR descriptions.
|
||||||
|
Don't be a meatproxy. We will ban people who repeatedly violate this rule from further communication on our repositories.
|
||||||
|
|
||||||
|
### Before you contribute, ask yourself what value you add
|
||||||
|
|
||||||
|
We much prefer reading your thoughts on features or issues you encountered than reading through hundreds of lines of code changes produced by your agent.
|
||||||
|
Consider that the maintainers of this project have the same access to LLMs and more knowledge about the project itself.
|
||||||
|
Prompting a feature into existence without further consideration or discussion is mostly a burden for maintainers.
|
||||||
|
If we want to, we can burn the tokens ourselves.
|
||||||
|
|
||||||
### Only work on approved issues
|
### Only work on approved issues
|
||||||
|
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -30,8 +30,16 @@ If you do not want to self-host solidtime or try it out you can sign up for [sol
|
|||||||
|
|
||||||
## Issues & Feature Requests
|
## 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
|
||||||
If you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
|
* 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.
|
||||||
|
* have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
|
||||||
|
|
||||||
|
|
||||||
|
> **Do not use AI-generated text in issues, discussions and PR descriptions.**
|
||||||
|
>
|
||||||
|
> We are human, so please write in natural language in your issues, discussions, and PR descriptions.
|
||||||
|
> Don't be a meatproxy. We will ban people who repeatedly violate this rule from further communication on our repositories.
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Correction;
|
||||||
|
|
||||||
|
use App\Jobs\RecalculateSpentTimeForProject;
|
||||||
|
use App\Jobs\RecalculateSpentTimeForTask;
|
||||||
|
use App\Models\TimeEntry;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class CorrectionDeleteTimeEntriesWithEndBeforeStartCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'correction:delete-time-entries-with-end-before-start '.
|
||||||
|
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Deletes all time entries where the end is before the start';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->comment('Deletes all time entries where the end is before the start...');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$timeEntries = TimeEntry::query()
|
||||||
|
->whereNotNull('end')
|
||||||
|
->whereColumn('end', '<', 'start')
|
||||||
|
->with(['project', 'task'])
|
||||||
|
->get();
|
||||||
|
foreach ($timeEntries as $timeEntry) {
|
||||||
|
/** @var TimeEntry $timeEntry */
|
||||||
|
if (! $dryRun) {
|
||||||
|
// Delete via Eloquent, so that the deletion is audited
|
||||||
|
$timeEntry->delete();
|
||||||
|
if ($timeEntry->project !== null) {
|
||||||
|
RecalculateSpentTimeForProject::dispatch($timeEntry->project);
|
||||||
|
}
|
||||||
|
if ($timeEntry->task !== null) {
|
||||||
|
RecalculateSpentTimeForTask::dispatch($timeEntry->task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->line('Deleted time entry (id='.$timeEntry->getKey().', start='.$timeEntry->start->toIso8601ZuluString().', end='.$timeEntry->end?->toIso8601ZuluString().')');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ class SelfHostDatabaseConsistency extends Command
|
|||||||
$problems = DB::table('time_entries')
|
$problems = DB::table('time_entries')
|
||||||
->select(['time_entries.id as id'])
|
->select(['time_entries.id as id'])
|
||||||
->join('tasks', 'time_entries.task_id', '=', 'tasks.id')
|
->join('tasks', 'time_entries.task_id', '=', 'tasks.id')
|
||||||
->where('tasks.project_id', '!=', DB::raw('time_entries.project_id'))
|
->whereRaw('tasks.project_id is distinct from time_entries.project_id')
|
||||||
->get();
|
->get();
|
||||||
$this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem);
|
$this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem);
|
||||||
|
|
||||||
@@ -59,6 +59,14 @@ class SelfHostDatabaseConsistency extends Command
|
|||||||
->get();
|
->get();
|
||||||
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
|
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
|
||||||
|
|
||||||
|
// End of time entries can not be before the start
|
||||||
|
$problems = DB::table('time_entries')
|
||||||
|
->select(['id'])
|
||||||
|
->whereNotNull('end')
|
||||||
|
->whereColumn('end', '<', 'start')
|
||||||
|
->get();
|
||||||
|
$this->logProblems($problems, 'Time entries have an end that is before the start', $hadAProblem);
|
||||||
|
|
||||||
// Every user needs to be a member of at least one organization
|
// Every user needs to be a member of at least one organization
|
||||||
$problems = DB::table('users')
|
$problems = DB::table('users')
|
||||||
->select(['users.id as id'])
|
->select(['users.id as id'])
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class TimeEntryMemberCanNotBeChangedWhileRunningApiException extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'time_entry_member_can_not_be_changed_while_running';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,7 +183,7 @@ class OrganizationResource extends Resource
|
|||||||
->action(function (Organization $record, array $data): void {
|
->action(function (Organization $record, array $data): void {
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
|
$file = Storage::disk(config('filesystems.private'))->get($data['file']);
|
||||||
} catch (UnableToReadFile) {
|
} catch (UnableToReadFile) {
|
||||||
$file = null;
|
$file = null;
|
||||||
}
|
}
|
||||||
@@ -220,12 +220,17 @@ class OrganizationResource extends Resource
|
|||||||
->body('Message: '.$exception->getMessage())
|
->body('Message: '.$exception->getMessage())
|
||||||
->persistent()
|
->persistent()
|
||||||
->send();
|
->send();
|
||||||
|
} finally {
|
||||||
|
Storage::disk(config('filesystems.private'))->delete($data['file']);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
->tooltip(fn (Organization $record): string => 'Import into '.$record->name)
|
->tooltip(fn (Organization $record): string => 'Import into '.$record->name)
|
||||||
->schema([
|
->schema([
|
||||||
FileUpload::make('file')
|
FileUpload::make('file')
|
||||||
->label('File')
|
->label('File')
|
||||||
|
->disk(config('filesystems.private'))
|
||||||
|
->directory('admin-import-uploads')
|
||||||
|
->visibility('private')
|
||||||
->required(),
|
->required(),
|
||||||
Select::make('type')
|
Select::make('type')
|
||||||
->required()
|
->required()
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Service\BillableRateService;
|
||||||
use App\Service\DashboardService;
|
use App\Service\DashboardService;
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
@@ -154,12 +154,12 @@ class ChartController extends Controller
|
|||||||
*
|
*
|
||||||
* @response array{value: int, currency: string}
|
* @response array{value: int, currency: string}
|
||||||
*/
|
*/
|
||||||
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse
|
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService, BillableRateService $billableRateService): JsonResponse
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'charts:view:own');
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
|
||||||
if (! $showBillableRate) {
|
if (! $showBillableRate) {
|
||||||
throw new AuthorizationException('You do not have permission to view billable rates.');
|
throw new AuthorizationException('You do not have permission to view billable rates.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,4 +49,9 @@ class Controller extends \App\Http\Controllers\Controller
|
|||||||
{
|
{
|
||||||
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
|
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function canUseBillableRates(Organization $organization): bool
|
||||||
|
{
|
||||||
|
return app(BillingContract::class)->canUseBillableRates($organization);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,10 +81,9 @@ class MemberController extends Controller
|
|||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'members:update', $member);
|
$this->checkPermission($organization, 'members:update', $member);
|
||||||
|
|
||||||
if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) {
|
$oldBillableRate = $member->billable_rate;
|
||||||
|
if ($request->has('billable_rate') && $this->canUseBillableRates($organization)) {
|
||||||
$member->billable_rate = $request->getBillableRate();
|
$member->billable_rate = $request->getBillableRate();
|
||||||
|
|
||||||
$billableRateService->updateTimeEntriesBillableRateForMember($member);
|
|
||||||
}
|
}
|
||||||
if ($request->has('role') && $member->role !== $request->getRole()->value) {
|
if ($request->has('role') && $member->role !== $request->getRole()->value) {
|
||||||
$newRole = $request->getRole();
|
$newRole = $request->getRole();
|
||||||
@@ -93,6 +92,10 @@ class MemberController extends Controller
|
|||||||
}
|
}
|
||||||
$member->save();
|
$member->save();
|
||||||
|
|
||||||
|
if ($oldBillableRate !== $member->billable_rate) {
|
||||||
|
$billableRateService->updateTimeEntriesBillableRateForMember($member);
|
||||||
|
}
|
||||||
|
|
||||||
return new MemberResource($member);
|
return new MemberResource($member);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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\Events\AfterCreateOrganization;
|
use App\Events\AfterCreateOrganization;
|
||||||
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
|
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
|
||||||
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
||||||
@@ -28,11 +27,11 @@ class OrganizationController extends Controller
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function show(Organization $organization): OrganizationResource
|
public function show(Organization $organization, BillableRateService $billableRateService): OrganizationResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'organizations:view');
|
$this->checkPermission($organization, 'organizations:view');
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
|
||||||
|
|
||||||
return new OrganizationResource($organization, $showBillableRate);
|
return new OrganizationResource($organization, $showBillableRate);
|
||||||
}
|
}
|
||||||
@@ -47,6 +46,7 @@ class OrganizationController extends Controller
|
|||||||
public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource
|
public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'organizations:update');
|
$this->checkPermission($organization, 'organizations:update');
|
||||||
|
$canUseBillableRates = $this->canUseBillableRates($organization);
|
||||||
|
|
||||||
if ($request->getName() !== null) {
|
if ($request->getName() !== null) {
|
||||||
$organization->name = $request->getName();
|
$organization->name = $request->getName();
|
||||||
@@ -54,7 +54,8 @@ class OrganizationController extends Controller
|
|||||||
if ($request->getCurrency() !== null) {
|
if ($request->getCurrency() !== null) {
|
||||||
$organization->currency = $request->getCurrency();
|
$organization->currency = $request->getCurrency();
|
||||||
}
|
}
|
||||||
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
// Organizations that can not use billable rates can keep or remove the visibility for employees, but not enable it
|
||||||
|
if ($request->getEmployeesCanSeeBillableRates() !== null && ($canUseBillableRates || ! $request->getEmployeesCanSeeBillableRates())) {
|
||||||
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
||||||
}
|
}
|
||||||
if ($request->getEmployeesCanManageTasks() !== null) {
|
if ($request->getEmployeesCanManageTasks() !== null) {
|
||||||
@@ -81,14 +82,13 @@ class OrganizationController extends Controller
|
|||||||
if ($request->getBreaksEnabled() !== null) {
|
if ($request->getBreaksEnabled() !== null) {
|
||||||
$organization->breaks_enabled = $request->getBreaksEnabled();
|
$organization->breaks_enabled = $request->getBreaksEnabled();
|
||||||
}
|
}
|
||||||
$hasBillableRate = $request->has('billable_rate');
|
$oldBillableRate = $organization->billable_rate;
|
||||||
if ($hasBillableRate) {
|
if ($request->has('billable_rate') && $canUseBillableRates) {
|
||||||
$oldBillableRate = $organization->billable_rate;
|
|
||||||
$organization->billable_rate = $request->getBillableRate();
|
$organization->billable_rate = $request->getBillableRate();
|
||||||
}
|
}
|
||||||
$organization->save();
|
$organization->save();
|
||||||
|
|
||||||
if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {
|
if ($oldBillableRate !== $organization->billable_rate) {
|
||||||
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
|
$billableRateService->updateTimeEntriesBillableRateForOrganization($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;
|
||||||
@@ -39,7 +38,7 @@ class ProjectController extends Controller
|
|||||||
*
|
*
|
||||||
* @operationId getProjects
|
* @operationId getProjects
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization, ProjectIndexRequest $request): ProjectCollection
|
public function index(Organization $organization, ProjectIndexRequest $request, BillableRateService $billableRateService): ProjectCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'projects:view');
|
$this->checkPermission($organization, 'projects:view');
|
||||||
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
|
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
|
||||||
@@ -63,7 +62,7 @@ class ProjectController extends Controller
|
|||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
|
||||||
|
|
||||||
return new ProjectCollection($projects, $showBillableRate);
|
return new ProjectCollection($projects, $showBillableRate);
|
||||||
}
|
}
|
||||||
@@ -97,11 +96,12 @@ class ProjectController extends Controller
|
|||||||
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
|
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'projects:create');
|
$this->checkPermission($organization, 'projects:create');
|
||||||
|
$canUseBillableRates = $this->canUseBillableRates($organization);
|
||||||
$project = new Project;
|
$project = new Project;
|
||||||
$project->name = $request->input('name');
|
$project->name = $request->input('name');
|
||||||
$project->color = $request->input('color');
|
$project->color = $request->input('color');
|
||||||
$project->is_billable = (bool) $request->input('is_billable');
|
$project->is_billable = $canUseBillableRates && $request->boolean('is_billable');
|
||||||
$project->billable_rate = $request->getBillableRate();
|
$project->billable_rate = $canUseBillableRates ? $request->getBillableRate() : null;
|
||||||
$project->client_id = $request->input('client_id');
|
$project->client_id = $request->input('client_id');
|
||||||
$project->is_public = $request->getIsPublic();
|
$project->is_public = $request->getIsPublic();
|
||||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||||
@@ -123,9 +123,13 @@ class ProjectController extends Controller
|
|||||||
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'projects:update', $project);
|
$this->checkPermission($organization, 'projects:update', $project);
|
||||||
|
$canUseBillableRates = $this->canUseBillableRates($organization);
|
||||||
$project->name = $request->input('name');
|
$project->name = $request->input('name');
|
||||||
$project->color = $request->input('color');
|
$project->color = $request->input('color');
|
||||||
$project->is_billable = (bool) $request->input('is_billable');
|
// Organizations that can not use billable rates can keep or remove the billable flag, but not set it
|
||||||
|
if ($canUseBillableRates || ! $request->boolean('is_billable')) {
|
||||||
|
$project->is_billable = $request->boolean('is_billable');
|
||||||
|
}
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -137,14 +141,16 @@ class ProjectController extends Controller
|
|||||||
}
|
}
|
||||||
$oldBillableRate = $project->billable_rate;
|
$oldBillableRate = $project->billable_rate;
|
||||||
$clientIdChanged = false;
|
$clientIdChanged = false;
|
||||||
$project->billable_rate = $request->getBillableRate();
|
if ($request->has('billable_rate') && $canUseBillableRates) {
|
||||||
|
$project->billable_rate = $request->getBillableRate();
|
||||||
|
}
|
||||||
if ($project->client_id !== $request->input('client_id')) {
|
if ($project->client_id !== $request->input('client_id')) {
|
||||||
$project->client_id = $request->input('client_id');
|
$project->client_id = $request->input('client_id');
|
||||||
$clientIdChanged = true;
|
$clientIdChanged = true;
|
||||||
}
|
}
|
||||||
$project->save();
|
$project->save();
|
||||||
|
|
||||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
if ($oldBillableRate !== $project->billable_rate) {
|
||||||
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
||||||
}
|
}
|
||||||
if ($clientIdChanged) {
|
if ($clientIdChanged) {
|
||||||
|
|||||||
@@ -73,13 +73,13 @@ class ProjectMemberController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$projectMember = new ProjectMember;
|
$projectMember = new ProjectMember;
|
||||||
$projectMember->billable_rate = $request->getBillableRate();
|
$projectMember->billable_rate = $this->canUseBillableRates($organization) ? $request->getBillableRate() : null;
|
||||||
$projectMember->member()->associate($member);
|
$projectMember->member()->associate($member);
|
||||||
$projectMember->user()->associate($member->user);
|
$projectMember->user()->associate($member->user);
|
||||||
$projectMember->project()->associate($project);
|
$projectMember->project()->associate($project);
|
||||||
$projectMember->save();
|
$projectMember->save();
|
||||||
|
|
||||||
if ($request->getBillableRate() !== null) {
|
if ($projectMember->billable_rate !== null) {
|
||||||
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,10 +97,12 @@ class ProjectMemberController extends Controller
|
|||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
||||||
$oldBillableRate = $projectMember->billable_rate;
|
$oldBillableRate = $projectMember->billable_rate;
|
||||||
$projectMember->billable_rate = $request->getBillableRate();
|
if ($this->canUseBillableRates($organization)) {
|
||||||
|
$projectMember->billable_rate = $request->getBillableRate();
|
||||||
|
}
|
||||||
$projectMember->save();
|
$projectMember->save();
|
||||||
|
|
||||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
if ($oldBillableRate !== $projectMember->billable_rate) {
|
||||||
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Enums\TimeEntryType;
|
use App\Enums\TimeEntryType;
|
||||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
|
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
|
||||||
@@ -29,6 +29,7 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
|
use App\Service\BillableRateService;
|
||||||
use App\Service\LocalizationService;
|
use App\Service\LocalizationService;
|
||||||
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
|
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
|
||||||
use App\Service\ReportExport\TimeEntriesDetailedExport;
|
use App\Service\ReportExport\TimeEntriesDetailedExport;
|
||||||
@@ -51,6 +52,7 @@ use Illuminate\Support\Carbon;
|
|||||||
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\Blade;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -227,7 +229,7 @@ class TimeEntryController extends Controller
|
|||||||
*
|
*
|
||||||
* @operationId exportTimeEntries
|
* @operationId exportTimeEntries
|
||||||
*/
|
*/
|
||||||
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
$member = $this->member($organization);
|
||||||
/** @var Member|null $memberFilter */
|
/** @var Member|null $memberFilter */
|
||||||
@@ -245,7 +247,7 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$timezone = $user->timezone;
|
$timezone = $user->timezone;
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
@@ -384,7 +386,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 $timeEntryAggregationService, BillableRateService $billableRateService): array
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
$member = $this->member($organization);
|
||||||
/** @var Member|null $memberFilter */
|
/** @var Member|null $memberFilter */
|
||||||
@@ -396,7 +398,7 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
|
||||||
|
|
||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
@@ -434,7 +436,7 @@ class TimeEntryController extends Controller
|
|||||||
* @throws NoOutputFileInResponse
|
* @throws NoOutputFileInResponse
|
||||||
* @throws FeatureIsNotAvailableInFreePlanApiException
|
* @throws FeatureIsNotAvailableInFreePlanApiException
|
||||||
*/
|
*/
|
||||||
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
$member = $this->member($organization);
|
||||||
/** @var Member|null $memberFilter */
|
/** @var Member|null $memberFilter */
|
||||||
@@ -451,7 +453,7 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$debug = $request->getDebug();
|
$debug = $request->getDebug();
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
|
||||||
|
|
||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
@@ -613,7 +615,24 @@ class TimeEntryController extends Controller
|
|||||||
$this->checkPermission($organization, 'time-entries:create:all');
|
$this->checkPermission($organization, 'time-entries:create:all');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) {
|
// Lock the creation of running time entries per user, so that concurrent requests can not create more than one running time entry
|
||||||
|
$lock = $request->input('end') === null ? Cache::lock('time-entries:running:'.$member->user_id, 10) : null;
|
||||||
|
$lock?->block(5);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->storeTimeEntry($organization, $member, $request);
|
||||||
|
} finally {
|
||||||
|
$lock?->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws TimeEntryStillRunningApiException
|
||||||
|
*/
|
||||||
|
private function storeTimeEntry(Organization $organization, Member $member, TimeEntryStoreRequest $request): JsonResource
|
||||||
|
{
|
||||||
|
// A user can only have one running time entry, across all organizations
|
||||||
|
if ($request->input('end') === null && TimeEntry::query()->where('user_id', $member->user_id)->whereNull('end')->exists()) {
|
||||||
throw new TimeEntryStillRunningApiException;
|
throw new TimeEntryStillRunningApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,6 +647,10 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$timeEntry = new TimeEntry;
|
$timeEntry = new TimeEntry;
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
|
// Organizations that can not use billable rates can not create billable time entries
|
||||||
|
if ($timeEntry->billable && ! $this->canUseBillableRates($organization)) {
|
||||||
|
$timeEntry->billable = false;
|
||||||
|
}
|
||||||
$timeEntry->client()->associate($client);
|
$timeEntry->client()->associate($client);
|
||||||
$timeEntry->user_id = $member->user_id;
|
$timeEntry->user_id = $member->user_id;
|
||||||
$timeEntry->description = $request->input('description') ?? '';
|
$timeEntry->description = $request->input('description') ?? '';
|
||||||
@@ -650,7 +673,7 @@ class TimeEntryController extends Controller
|
|||||||
*
|
*
|
||||||
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
|
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
|
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException|TimeEntryMemberCanNotBeChangedWhileRunningApiException
|
||||||
*
|
*
|
||||||
* @operationId updateTimeEntry
|
* @operationId updateTimeEntry
|
||||||
*/
|
*/
|
||||||
@@ -676,6 +699,11 @@ class TimeEntryController extends Controller
|
|||||||
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
|
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
|
||||||
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
|
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
|
||||||
|
|
||||||
|
// The new member could already have a running time entry
|
||||||
|
if ($effectiveEnd === null && $newMember !== null && $newMember->getKey() !== $timeEntry->member_id) {
|
||||||
|
throw new TimeEntryMemberCanNotBeChangedWhileRunningApiException;
|
||||||
|
}
|
||||||
|
|
||||||
$oldProject = $timeEntry->project;
|
$oldProject = $timeEntry->project;
|
||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
|
||||||
@@ -690,11 +718,20 @@ class TimeEntryController extends Controller
|
|||||||
$task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;
|
$task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$wasBillable = $timeEntry->billable;
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
|
// Organizations that can not use billable rates can keep or remove the billable flag, but not set it
|
||||||
|
if ($timeEntry->billable && ! $wasBillable && ! $this->canUseBillableRates($organization)) {
|
||||||
|
$timeEntry->billable = false;
|
||||||
|
}
|
||||||
if ($newMember !== null) {
|
if ($newMember !== null) {
|
||||||
$timeEntry->member()->associate($newMember);
|
$timeEntry->member()->associate($newMember);
|
||||||
$timeEntry->user()->associate($newMember->user);
|
$timeEntry->user()->associate($newMember->user);
|
||||||
}
|
}
|
||||||
|
// If project is changed, but task is not, we remove the old task from the time entry
|
||||||
|
if ($request->has('project_id') && ! $request->has('task_id') && $oldTask !== null && $oldTask->project_id !== $project?->getKey()) {
|
||||||
|
$timeEntry->task()->disassociate();
|
||||||
|
}
|
||||||
$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();
|
||||||
@@ -744,6 +781,7 @@ class TimeEntryController extends Controller
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
$changes = $request->validated('changes');
|
$changes = $request->validated('changes');
|
||||||
|
$canUseBillableRates = $this->canUseBillableRates($organization);
|
||||||
|
|
||||||
if ($request->has('changes.description')) {
|
if ($request->has('changes.description')) {
|
||||||
$changes['description'] = $request->input('changes.description') ?? '';
|
$changes['description'] = $request->input('changes.description') ?? '';
|
||||||
@@ -801,16 +839,27 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// The new member could already have a running time entry
|
||||||
|
if ($newMember !== null && $timeEntry->end === null && $newMember->getKey() !== $timeEntry->member_id) {
|
||||||
|
$error->push($id);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$oldProject = $timeEntry->project;
|
$oldProject = $timeEntry->project;
|
||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
$wasBillable = $timeEntry->billable;
|
||||||
|
|
||||||
$timeEntry->fill($changes);
|
$timeEntry->fill($changes);
|
||||||
|
// Organizations that can not use billable rates can keep or remove the billable flag, but not set it
|
||||||
|
if (! $wasBillable && ! $canUseBillableRates) {
|
||||||
|
$timeEntry->billable = false;
|
||||||
|
}
|
||||||
if ($newMember !== null) {
|
if ($newMember !== null) {
|
||||||
$timeEntry->member()->associate($newMember);
|
$timeEntry->member()->associate($newMember);
|
||||||
$timeEntry->user_id = $newMember->user_id;
|
$timeEntry->user_id = $newMember->user_id;
|
||||||
}
|
}
|
||||||
// If project is changed, but task is not, we remove the old task from the time entry
|
// 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) {
|
if ($request->has('changes.project_id') && ! $request->has('changes.task_id') && $oldTask !== null && $oldTask->project_id !== $project?->getKey()) {
|
||||||
$timeEntry->task()->disassociate();
|
$timeEntry->task()->disassociate();
|
||||||
}
|
}
|
||||||
if ($overwriteClient) {
|
if ($overwriteClient) {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
||||||
'is_blocked' => $billing->isBlocked($currentOrganization),
|
'is_blocked' => $billing->isBlocked($currentOrganization),
|
||||||
'goal_limit' => $billing->getGoalLimit($currentOrganization),
|
'goal_limit' => $billing->getGoalLimit($currentOrganization),
|
||||||
|
'can_use_billable_rates' => $billing->canUseBillableRates($currentOrganization),
|
||||||
] : null,
|
] : null,
|
||||||
'flash' => [
|
'flash' => [
|
||||||
'message' => fn () => $request->session()->get('message'),
|
'message' => fn () => $request->session()->get('message'),
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class ProjectUpdateRequest extends BaseFormRequest
|
|||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
})->uuid(),
|
||||||
],
|
],
|
||||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency), the current rate is kept if the field is omitted
|
||||||
'billable_rate' => array_merge([
|
'billable_rate' => array_merge([
|
||||||
'nullable',
|
'nullable',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ use App\Models\Tag;
|
|||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
|
use Carbon\Exceptions\InvalidFormatException;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Validation\ConditionalRules;
|
use Illuminate\Validation\ConditionalRules;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@@ -90,12 +92,32 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
|||||||
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
||||||
'start' => [
|
'start' => [
|
||||||
'date_format:Y-m-d\TH:i:s\Z',
|
'date_format:Y-m-d\TH:i:s\Z',
|
||||||
|
function (string $attribute, mixed $value, Closure $fail) use ($timeEntry): void {
|
||||||
|
// If the payload does not contain an end, the start needs to be validated against the persisted end
|
||||||
|
if ($this->has('end') || $timeEntry?->end === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$start = $this->parseDate($value);
|
||||||
|
if ($start !== null && $start->gt($timeEntry->end)) {
|
||||||
|
$fail('The start field must be a date before or equal to end.');
|
||||||
|
}
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
||||||
'end' => [
|
'end' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'date_format:Y-m-d\TH:i:s\Z',
|
'date_format:Y-m-d\TH:i:s\Z',
|
||||||
'after_or_equal:start',
|
'after_or_equal:start',
|
||||||
|
function (string $attribute, mixed $value, Closure $fail) use ($timeEntry): void {
|
||||||
|
// If the payload does not contain a start, the end needs to be validated against the persisted start
|
||||||
|
if ($this->has('start') || $timeEntry === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$end = $this->parseDate($value);
|
||||||
|
if ($end !== null && $end->lt($timeEntry->start)) {
|
||||||
|
$fail('The end field must be a date after or equal to start.');
|
||||||
|
}
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Whether time entry is billable
|
// Whether time entry is billable
|
||||||
'billable' => [
|
'billable' => [
|
||||||
@@ -137,4 +159,16 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function parseDate(mixed $value): ?Carbon
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $value, 'UTC');
|
||||||
|
} catch (InvalidFormatException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Enums\NumberFormat;
|
|||||||
use App\Enums\TimeFormat;
|
use App\Enums\TimeFormat;
|
||||||
use App\Http\Resources\V1\BaseResource;
|
use App\Http\Resources\V1\BaseResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Service\BillingContract;
|
||||||
use App\Service\CurrencyService;
|
use App\Service\CurrencyService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@@ -51,6 +52,8 @@ class OrganizationResource extends BaseResource
|
|||||||
'is_personal' => $this->resource->personal_team,
|
'is_personal' => $this->resource->personal_team,
|
||||||
/** Billable rate in cents per hour */
|
/** Billable rate in cents per hour */
|
||||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||||
|
/** Whether the organization's plan allows billable flags and rates */
|
||||||
|
'can_use_billable_rates' => app(BillingContract::class)->canUseBillableRates($this->resource),
|
||||||
/** Can members of the organization with role "employee" see the 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,
|
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||||
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
@@ -13,6 +14,24 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
|
|
||||||
class BillableRateService
|
class BillableRateService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private BillingContract $billing,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function canUseBillableRates(Organization $organization): bool
|
||||||
|
{
|
||||||
|
return $this->billing->canUseBillableRates($organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canSeeBillableRates(Organization $organization, Member $member): bool
|
||||||
|
{
|
||||||
|
if (! $this->canUseBillableRates($organization)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $member->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
}
|
||||||
|
|
||||||
public function updateTimeEntriesBillableRateForProjectMember(ProjectMember $projectMember): void
|
public function updateTimeEntriesBillableRateForProjectMember(ProjectMember $projectMember): void
|
||||||
{
|
{
|
||||||
TimeEntry::query()
|
TimeEntry::query()
|
||||||
|
|||||||
@@ -63,4 +63,13 @@ class BillingContract
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the organization can set and see billable rates
|
||||||
|
* Without a billing system every organization can use billable rates
|
||||||
|
*/
|
||||||
|
public function canUseBillableRates(Organization $organization): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,13 +116,13 @@ class ReportPropertiesDto implements Castable
|
|||||||
$dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null;
|
$dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null;
|
||||||
$dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null;
|
$dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null;
|
||||||
$dto->active = $data->active;
|
$dto->active = $data->active;
|
||||||
$dto->memberIds = $data->memberIds !== null ? TimeEntryFilter::idArrayToCollection($data->memberIds) : null;
|
$dto->setMemberIds($data->memberIds !== null ? (array) $data->memberIds : null);
|
||||||
$dto->billable = $data->billable;
|
$dto->billable = $data->billable;
|
||||||
$dto->clientIds = $data->clientIds !== null ? TimeEntryFilter::idArrayToCollection($data->clientIds) : null;
|
$dto->setClientIds($data->clientIds !== null ? (array) $data->clientIds : null);
|
||||||
$dto->projectIds = $data->projectIds !== null ? TimeEntryFilter::idArrayToCollection($data->projectIds) : null;
|
$dto->setProjectIds($data->projectIds !== null ? (array) $data->projectIds : null);
|
||||||
$dto->tagIds = $data->tagIds !== null ? TimeEntryFilter::idArrayToCollection($data->tagIds) : null;
|
$dto->setTagIds($data->tagIds !== null ? (array) $data->tagIds : null);
|
||||||
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
|
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
|
||||||
$dto->taskIds = $data->taskIds ? TimeEntryFilter::idArrayToCollection($data->taskIds) : null;
|
$dto->setTaskIds($data->taskIds !== null ? (array) $data->taskIds : null);
|
||||||
$dto->group = TimeEntryAggregationType::from($data->group);
|
$dto->group = TimeEntryAggregationType::from($data->group);
|
||||||
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
|
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
|
||||||
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
||||||
@@ -184,7 +184,7 @@ class ReportPropertiesDto implements Castable
|
|||||||
*/
|
*/
|
||||||
public function setMemberIds(?array $memberIds): void
|
public function setMemberIds(?array $memberIds): void
|
||||||
{
|
{
|
||||||
$this->memberIds = $memberIds !== null ? TimeEntryFilter::idArrayToCollection($memberIds) : null;
|
$this->memberIds = $memberIds !== null && count($memberIds) > 0 ? TimeEntryFilter::idArrayToCollection($memberIds) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,7 +192,7 @@ class ReportPropertiesDto implements Castable
|
|||||||
*/
|
*/
|
||||||
public function setClientIds(?array $clientIds): void
|
public function setClientIds(?array $clientIds): void
|
||||||
{
|
{
|
||||||
$this->clientIds = $clientIds !== null ? TimeEntryFilter::idArrayToCollection($clientIds) : null;
|
$this->clientIds = $clientIds !== null && count($clientIds) > 0 ? TimeEntryFilter::idArrayToCollection($clientIds) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,7 +200,7 @@ class ReportPropertiesDto implements Castable
|
|||||||
*/
|
*/
|
||||||
public function setProjectIds(?array $projectIds): void
|
public function setProjectIds(?array $projectIds): void
|
||||||
{
|
{
|
||||||
$this->projectIds = $projectIds !== null ? TimeEntryFilter::idArrayToCollection($projectIds) : null;
|
$this->projectIds = $projectIds !== null && count($projectIds) > 0 ? TimeEntryFilter::idArrayToCollection($projectIds) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,7 +208,7 @@ class ReportPropertiesDto implements Castable
|
|||||||
*/
|
*/
|
||||||
public function setTagIds(?array $tagIds): void
|
public function setTagIds(?array $tagIds): void
|
||||||
{
|
{
|
||||||
$this->tagIds = $tagIds !== null ? TimeEntryFilter::idArrayToCollection($tagIds) : null;
|
$this->tagIds = $tagIds !== null && count($tagIds) > 0 ? TimeEntryFilter::idArrayToCollection($tagIds) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setTagMatchType(?TagMatchType $tagMatchType): void
|
public function setTagMatchType(?TagMatchType $tagMatchType): void
|
||||||
@@ -221,6 +221,6 @@ class ReportPropertiesDto implements Castable
|
|||||||
*/
|
*/
|
||||||
public function setTaskIds(?array $taskIds): void
|
public function setTaskIds(?array $taskIds): void
|
||||||
{
|
{
|
||||||
$this->taskIds = $taskIds !== null ? TimeEntryFilter::idArrayToCollection($taskIds) : null;
|
$this->taskIds = $taskIds !== null && count($taskIds) > 0 ? TimeEntryFilter::idArrayToCollection($taskIds) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ use App\Service\Import\Importers\ImporterContract;
|
|||||||
use App\Service\Import\Importers\ImporterProvider;
|
use App\Service\Import\Importers\ImporterProvider;
|
||||||
use App\Service\Import\Importers\ImportException;
|
use App\Service\Import\Importers\ImportException;
|
||||||
use App\Service\Import\Importers\ReportDto;
|
use App\Service\Import\Importers\ReportDto;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class ImportService
|
class ImportService
|
||||||
{
|
{
|
||||||
@@ -22,6 +25,8 @@ class ImportService
|
|||||||
/** @var ImporterContract $importer */
|
/** @var ImporterContract $importer */
|
||||||
$importer = app(ImporterProvider::class)->getImporter($importerType);
|
$importer = app(ImporterProvider::class)->getImporter($importerType);
|
||||||
$importer->init($organization);
|
$importer->init($organization);
|
||||||
|
Storage::disk(config('filesystems.private'))
|
||||||
|
->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data);
|
||||||
|
|
||||||
$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);
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||||
throw new ImportException('Invalid billable value');
|
throw new ImportException('Invalid billable value');
|
||||||
}
|
}
|
||||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
$timeEntry->billable = $this->canUseBillableRates && $record['Billable'] === 'Yes';
|
||||||
}
|
}
|
||||||
if ($isBreak) {
|
if ($isBreak) {
|
||||||
// Breaks can not be billable or belong to a project/task (already skipped above)
|
// Breaks can not be billable or belong to a project/task (already skipped above)
|
||||||
@@ -190,6 +190,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
if ($end === null) {
|
if ($end === null) {
|
||||||
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
|
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
|
||||||
}
|
}
|
||||||
|
if ($end->lt($start)) {
|
||||||
|
throw new ImportException('End ("'.$endStr.'") is before start ("'.$startStr.'")');
|
||||||
|
}
|
||||||
$timeEntry->end = $end->utc();
|
$timeEntry->end = $end->utc();
|
||||||
|
|
||||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||||
|
|||||||
@@ -74,9 +74,16 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
|
|
||||||
protected BillableRateService $billableRateService;
|
protected BillableRateService $billableRateService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organizations that can not use billable rates import everything without billable flags and rates
|
||||||
|
*/
|
||||||
|
protected bool $canUseBillableRates;
|
||||||
|
|
||||||
public function init(Organization $organization): void
|
public function init(Organization $organization): void
|
||||||
{
|
{
|
||||||
$this->organization = $organization;
|
$this->organization = $organization;
|
||||||
|
$this->billableRateService = app(BillableRateService::class);
|
||||||
|
$this->canUseBillableRates = $this->billableRateService->canUseBillableRates($organization);
|
||||||
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
|
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
|
||||||
/** @var Builder<User> $builder */
|
/** @var Builder<User> $builder */
|
||||||
return $builder->belongsToOrganization($this->organization);
|
return $builder->belongsToOrganization($this->organization);
|
||||||
@@ -99,7 +106,11 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
'string',
|
'string',
|
||||||
'in:placeholder',
|
'in:placeholder',
|
||||||
],
|
],
|
||||||
]);
|
], beforeSave: function (Member $member): void {
|
||||||
|
if (! $this->canUseBillableRates) {
|
||||||
|
$member->billable_rate = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
|
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->where('organization_id', $this->organization->id);
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
@@ -126,6 +137,10 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
if ($project->billable_rate === 0) {
|
if ($project->billable_rate === 0) {
|
||||||
$project->billable_rate = null;
|
$project->billable_rate = null;
|
||||||
}
|
}
|
||||||
|
if (! $this->canUseBillableRates) {
|
||||||
|
$project->is_billable = false;
|
||||||
|
$project->billable_rate = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
$this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder): Builder {
|
$this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder): Builder {
|
||||||
/** @var Builder<ProjectMember> $builder */
|
/** @var Builder<ProjectMember> $builder */
|
||||||
@@ -140,6 +155,9 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
if ($projectMember->billable_rate === 0) {
|
if ($projectMember->billable_rate === 0) {
|
||||||
$projectMember->billable_rate = null;
|
$projectMember->billable_rate = null;
|
||||||
}
|
}
|
||||||
|
if (! $this->canUseBillableRates) {
|
||||||
|
$projectMember->billable_rate = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {
|
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Tag> $builder */
|
/** @var Builder<Tag> $builder */
|
||||||
@@ -181,7 +199,6 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
$this->timeEntriesCreated = 0;
|
$this->timeEntriesCreated = 0;
|
||||||
$this->colorService = app(ColorService::class);
|
$this->colorService = app(ColorService::class);
|
||||||
$this->timezoneService = app(TimezoneService::class);
|
$this->timezoneService = app(TimezoneService::class);
|
||||||
$this->billableRateService = app(BillableRateService::class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class GenericTimeEntriesImporter extends DefaultImporter
|
|||||||
if (! in_array($record['billable'], ['true', 'false'], true)) {
|
if (! in_array($record['billable'], ['true', 'false'], true)) {
|
||||||
throw new ImportException('Invalid billable value');
|
throw new ImportException('Invalid billable value');
|
||||||
}
|
}
|
||||||
$timeEntry->billable = $record['billable'] === 'true';
|
$timeEntry->billable = $this->canUseBillableRates && $record['billable'] === 'true';
|
||||||
$timeEntry->tags = $this->getTags($record['tags']);
|
$timeEntry->tags = $this->getTags($record['tags']);
|
||||||
$timeEntry->is_imported = true;
|
$timeEntry->is_imported = true;
|
||||||
try {
|
try {
|
||||||
@@ -155,6 +155,9 @@ class GenericTimeEntriesImporter extends DefaultImporter
|
|||||||
if ($end === null) {
|
if ($end === null) {
|
||||||
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
|
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
|
||||||
}
|
}
|
||||||
|
if ($end->lt($start)) {
|
||||||
|
throw new ImportException('Value of end ("'.$record['end'].'") is before start ("'.$record['start'].'")');
|
||||||
|
}
|
||||||
$timeEntry->end = $end->utc();
|
$timeEntry->end = $end->utc();
|
||||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||||
$timeEntry,
|
$timeEntry,
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
|||||||
if (! in_array($record['Billable?'], ['Yes', 'No'], true)) {
|
if (! in_array($record['Billable?'], ['Yes', 'No'], true)) {
|
||||||
throw new ImportException('Invalid billable value');
|
throw new ImportException('Invalid billable value');
|
||||||
}
|
}
|
||||||
$timeEntry->billable = $record['Billable?'] === 'Yes';
|
$timeEntry->billable = $this->canUseBillableRates && $record['Billable?'] === 'Yes';
|
||||||
$timeEntry->tags = [];
|
$timeEntry->tags = [];
|
||||||
$timeEntry->is_imported = true;
|
$timeEntry->is_imported = true;
|
||||||
|
|
||||||
@@ -136,6 +136,9 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
|||||||
throw new ImportException('Hours ("'.$record['Hours'].'") is invalid');
|
throw new ImportException('Hours ("'.$record['Hours'].'") is invalid');
|
||||||
}
|
}
|
||||||
$hours = (float) $hoursField;
|
$hours = (float) $hoursField;
|
||||||
|
if ($hours < 0) {
|
||||||
|
throw new ImportException('Hours ("'.$record['Hours'].'") is negative');
|
||||||
|
}
|
||||||
$timeEntry->start = $date->copy()->startOfDay()->utc();
|
$timeEntry->start = $date->copy()->startOfDay()->utc();
|
||||||
$timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc();
|
$timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc();
|
||||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||||
|
|||||||
@@ -211,18 +211,34 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
|
|
||||||
// Time entries
|
// Time entries
|
||||||
foreach ($timeEntriesReader as $timeEntryRow) {
|
foreach ($timeEntriesReader as $timeEntryRow) {
|
||||||
|
// Running time entries are skipped, since the user could already have a running time entry
|
||||||
|
if ($timeEntryRow['end'] === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']);
|
$userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']);
|
||||||
|
if ($userId === null) {
|
||||||
|
throw new ImportException('Time entry references a user that does not exist in the import');
|
||||||
|
}
|
||||||
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']);
|
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']);
|
||||||
|
if ($memberId === null) {
|
||||||
|
throw new ImportException('Time entry references a member that does not exist in the import');
|
||||||
|
}
|
||||||
$member = $this->memberImportHelper->getModelById($memberId);
|
$member = $this->memberImportHelper->getModelById($memberId);
|
||||||
$clientId = null;
|
$clientId = null;
|
||||||
if ($timeEntryRow['client_id'] !== '') {
|
if ($timeEntryRow['client_id'] !== '') {
|
||||||
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier($timeEntryRow['client_id']);
|
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier($timeEntryRow['client_id']);
|
||||||
|
if ($clientId === null) {
|
||||||
|
throw new ImportException('Time entry references a client that does not exist in the import');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$project = null;
|
$project = null;
|
||||||
$projectId = null;
|
$projectId = null;
|
||||||
$projectMember = null;
|
$projectMember = null;
|
||||||
if ($timeEntryRow['project_id'] !== '') {
|
if ($timeEntryRow['project_id'] !== '') {
|
||||||
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier($timeEntryRow['project_id']);
|
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier($timeEntryRow['project_id']);
|
||||||
|
if ($projectId === null) {
|
||||||
|
throw new ImportException('Time entry references a project that does not exist in the import');
|
||||||
|
}
|
||||||
$project = $this->projectImportHelper->getModelById($projectId);
|
$project = $this->projectImportHelper->getModelById($projectId);
|
||||||
$projectMember = $this->projectMemberImportHelper->getModel([
|
$projectMember = $this->projectMemberImportHelper->getModel([
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
@@ -232,6 +248,9 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$taskId = null;
|
$taskId = null;
|
||||||
if ($timeEntryRow['task_id'] !== '') {
|
if ($timeEntryRow['task_id'] !== '') {
|
||||||
$taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']);
|
$taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']);
|
||||||
|
if ($taskId === null) {
|
||||||
|
throw new ImportException('Time entry references a task that does not exist in the import');
|
||||||
|
}
|
||||||
$this->taskImportHelper->getModelById($taskId);
|
$this->taskImportHelper->getModelById($taskId);
|
||||||
}
|
}
|
||||||
$timeEntry = new TimeEntry;
|
$timeEntry = new TimeEntry;
|
||||||
@@ -249,7 +268,7 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) {
|
if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) {
|
||||||
throw new ImportException('Invalid billable value');
|
throw new ImportException('Invalid billable value');
|
||||||
}
|
}
|
||||||
$timeEntry->billable = $timeEntryRow['billable'] === 'true';
|
$timeEntry->billable = $this->canUseBillableRates && $timeEntryRow['billable'] === 'true';
|
||||||
// The type column does not exist in old exports
|
// The type column does not exist in old exports
|
||||||
if (($timeEntryRow['type'] ?? '') !== '') {
|
if (($timeEntryRow['type'] ?? '') !== '') {
|
||||||
$type = TimeEntryType::tryFrom($timeEntryRow['type']);
|
$type = TimeEntryType::tryFrom($timeEntryRow['type']);
|
||||||
@@ -271,19 +290,18 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
$timeEntry->start = $start->utc();
|
$timeEntry->start = $start->utc();
|
||||||
|
|
||||||
if ($timeEntryRow['end'] !== '') {
|
try {
|
||||||
try {
|
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
|
||||||
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
|
} catch (InvalidFormatException) {
|
||||||
} catch (InvalidFormatException) {
|
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
|
||||||
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
|
|
||||||
}
|
|
||||||
if ($end === null) {
|
|
||||||
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
|
|
||||||
}
|
|
||||||
$timeEntry->end = $end->utc();
|
|
||||||
} else {
|
|
||||||
$timeEntry->end = null;
|
|
||||||
}
|
}
|
||||||
|
if ($end === null) {
|
||||||
|
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
|
||||||
|
}
|
||||||
|
if ($end->lt($start)) {
|
||||||
|
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is before start date ("'.$timeEntryRow['start'].'")');
|
||||||
|
}
|
||||||
|
$timeEntry->end = $end->utc();
|
||||||
|
|
||||||
if ($timeEntryRow['still_active_email_sent_at'] !== '') {
|
if ($timeEntryRow['still_active_email_sent_at'] !== '') {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
|||||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||||
throw new ImportException('Invalid billable value');
|
throw new ImportException('Invalid billable value');
|
||||||
}
|
}
|
||||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
$timeEntry->billable = $this->canUseBillableRates && $record['Billable'] === 'Yes';
|
||||||
$timeEntry->tags = $this->getTags($record['Tags']);
|
$timeEntry->tags = $this->getTags($record['Tags']);
|
||||||
$timeEntry->is_imported = true;
|
$timeEntry->is_imported = true;
|
||||||
try {
|
try {
|
||||||
@@ -139,6 +139,9 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
|||||||
if ($end === null) {
|
if ($end === null) {
|
||||||
throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid');
|
throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid');
|
||||||
}
|
}
|
||||||
|
if ($end->lt($start)) {
|
||||||
|
throw new ImportException('End ("'.$record['End date'].' '.$record['End time'].'") is before start ("'.$record['Start date'].' '.$record['Start time'].'")');
|
||||||
|
}
|
||||||
$timeEntry->end = $end->utc();
|
$timeEntry->end = $end->utc();
|
||||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||||
$timeEntry,
|
$timeEntry,
|
||||||
|
|||||||
@@ -159,6 +159,13 @@ class MemberService
|
|||||||
|
|
||||||
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void
|
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void
|
||||||
{
|
{
|
||||||
|
// Running time entries are deleted instead of moved, since $toMember could already have a running time entry
|
||||||
|
TimeEntry::query()
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->whereBelongsTo($fromMember, 'member')
|
||||||
|
->whereNull('end')
|
||||||
|
->delete();
|
||||||
|
|
||||||
// Time entries
|
// Time entries
|
||||||
TimeEntry::query()
|
TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
|||||||
@@ -133,7 +133,8 @@ class TimeEntryFilter
|
|||||||
*/
|
*/
|
||||||
public function addMemberIdsFilter(?array $memberIds): self
|
public function addMemberIdsFilter(?array $memberIds): self
|
||||||
{
|
{
|
||||||
if ($memberIds === null) {
|
// An empty selection is no constraint, the same as null
|
||||||
|
if ($memberIds === null || count($memberIds) === 0) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$this->builder->whereIn('member_id', $memberIds);
|
$this->builder->whereIn('member_id', $memberIds);
|
||||||
@@ -203,6 +204,10 @@ class TimeEntryFilter
|
|||||||
}
|
}
|
||||||
$includeNone = in_array(self::NONE_VALUE, $clientIds, true);
|
$includeNone = in_array(self::NONE_VALUE, $clientIds, true);
|
||||||
$clientIds = array_values(array_filter($clientIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
$clientIds = array_values(array_filter($clientIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||||
|
// An empty selection (no client IDs and not filtering for "none") is no constraint, so apply nothing
|
||||||
|
if (count($clientIds) === 0 && ! $includeNone) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
$this->builder->where(function (Builder $builder) use ($clientIds, $includeNone): void {
|
$this->builder->where(function (Builder $builder) use ($clientIds, $includeNone): void {
|
||||||
if (count($clientIds) > 0) {
|
if (count($clientIds) > 0) {
|
||||||
@@ -226,6 +231,10 @@ class TimeEntryFilter
|
|||||||
}
|
}
|
||||||
$includeNone = in_array(self::NONE_VALUE, $projectIds, true);
|
$includeNone = in_array(self::NONE_VALUE, $projectIds, true);
|
||||||
$projectIds = array_values(array_filter($projectIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
$projectIds = array_values(array_filter($projectIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||||
|
// An empty selection (no project IDs and not filtering for "none") is no constraint, so apply nothing
|
||||||
|
if (count($projectIds) === 0 && ! $includeNone) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
$this->builder->where(function (Builder $builder) use ($projectIds, $includeNone): void {
|
$this->builder->where(function (Builder $builder) use ($projectIds, $includeNone): void {
|
||||||
if (count($projectIds) > 0) {
|
if (count($projectIds) > 0) {
|
||||||
@@ -291,6 +300,10 @@ class TimeEntryFilter
|
|||||||
}
|
}
|
||||||
$includeNone = in_array(self::NONE_VALUE, $taskIds, true);
|
$includeNone = in_array(self::NONE_VALUE, $taskIds, true);
|
||||||
$taskIds = array_values(array_filter($taskIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
$taskIds = array_values(array_filter($taskIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||||
|
// An empty selection (no task IDs and not filtering for "none") is no constraint, so apply nothing
|
||||||
|
if (count($taskIds) === 0 && ! $includeNone) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
$this->builder->where(function (Builder $builder) use ($taskIds, $includeNone): void {
|
$this->builder->where(function (Builder $builder) use ($taskIds, $includeNone): void {
|
||||||
if (count($taskIds) > 0) {
|
if (count($taskIds) > 0) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'zip_max_files' => (int) (env('IMPORT_ZIP_MAX_FILES') ?: 100),
|
'zip_max_files' => (int) (env('IMPORT_ZIP_MAX_FILES') ?: 10000),
|
||||||
|
|
||||||
'zip_max_uncompressed_size' => (int) (env('IMPORT_ZIP_MAX_UNCOMPRESSED_SIZE') ?: 500 * 1024 * 1024),
|
'zip_max_uncompressed_size' => (int) (env('IMPORT_ZIP_MAX_UNCOMPRESSED_SIZE') ?: 500 * 1024 * 1024),
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
ARG PHP_VERSION=8.3
|
ARG PHP_VERSION=8.3.36
|
||||||
ARG FRANKENPHP_VERSION=1.11
|
ARG FRANKENPHP_VERSION=1.13
|
||||||
ARG COMPOSER_VERSION=2.8
|
ARG COMPOSER_VERSION=2.8
|
||||||
ARG APP_ENV
|
ARG APP_ENV
|
||||||
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ realpath_cache_ttl = 720
|
|||||||
memory_limit = 256M
|
memory_limit = 256M
|
||||||
max_input_time = 5
|
max_input_time = 5
|
||||||
register_argc_argv = 0
|
register_argc_argv = 0
|
||||||
|
session.upload_progress.enabled = 0
|
||||||
date.timezone = ${TZ:-UTC}
|
date.timezone = ${TZ:-UTC}
|
||||||
|
|
||||||
[Opcache]
|
[Opcache]
|
||||||
|
|||||||
@@ -342,6 +342,89 @@ test('test that a running time entry counts towards the goal progress', async ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close to midnight UTC an entry that started a few minutes ago could fall on the previous day, so
|
||||||
|
// the day of the goal is moved to a timezone (without daylight saving time) where it is late morning
|
||||||
|
function timezoneAwayFromMidnight() {
|
||||||
|
const utcHour = new Date().getUTCHours();
|
||||||
|
return utcHour >= 2 && utcHour < 22 ? 'UTC' : 'Asia/Tokyo';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigates via the sidebar, a full page load would start with an empty query cache and hide
|
||||||
|
// a missing goals invalidation
|
||||||
|
async function navigateViaSidebar(page: Page, name: 'Time' | 'Goals') {
|
||||||
|
await page.getByRole('link', { name, exact: true }).first().click();
|
||||||
|
await expect(page).toHaveURL(new RegExp('/' + name.toLowerCase() + '$'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that goal progress updates after creating a time entry without a page reload', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const goal = await createGoalViaApi(ctx, {
|
||||||
|
name: 'Cache goal',
|
||||||
|
target_seconds: 3600,
|
||||||
|
period: 'day',
|
||||||
|
timezone: timezoneAwayFromMidnight(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToGoalsOverview(page);
|
||||||
|
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 00min');
|
||||||
|
|
||||||
|
await navigateViaSidebar(page, 'Time');
|
||||||
|
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||||
|
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||||
|
await durationInput.fill('15min');
|
||||||
|
await durationInput.press('Tab');
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await navigateViaSidebar(page, 'Goals');
|
||||||
|
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 15min');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that goal progress updates after changing the project of the running time entry without a page reload', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const project = await createProjectViaApi(ctx, { name: 'Running Project', is_public: true });
|
||||||
|
const start = new Date(Date.now() - 30 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
await createRunningTimeEntryWithStartViaApi(ctx, 'Running work', start);
|
||||||
|
const goal = await createGoalViaApi(ctx, {
|
||||||
|
name: 'Running project goal',
|
||||||
|
target_seconds: 3600,
|
||||||
|
period: 'day',
|
||||||
|
timezone: timezoneAwayFromMidnight(),
|
||||||
|
filters: { project_ids: [project.id], time_entry_type: 'work' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToGoalsOverview(page);
|
||||||
|
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 00min');
|
||||||
|
|
||||||
|
await navigateViaSidebar(page, 'Time');
|
||||||
|
await page.getByRole('button', { name: 'No Project' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200 &&
|
||||||
|
(await response.json()).data.project_id === project.id
|
||||||
|
),
|
||||||
|
page.getByRole('option').filter({ hasText: project.name }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await navigateViaSidebar(page, 'Goals');
|
||||||
|
// The entry keeps running while the test runs
|
||||||
|
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||||
|
/^0h 3[0-2]min$/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('test that editing a goal via the modal works', async ({ page, ctx }) => {
|
test('test that editing a goal via the modal works', async ({ page, ctx }) => {
|
||||||
const goal = await createGoalViaApi(ctx, {
|
const goal = await createGoalViaApi(ctx, {
|
||||||
name: 'Old goal name',
|
name: 'Old goal name',
|
||||||
|
|||||||
@@ -884,7 +884,7 @@ test('test that sorting members by billable rate works', async ({ page, ctx }) =
|
|||||||
await expect(table).toBeVisible();
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
// First click = desc (highest first), null rates last
|
// First click = desc (highest first), null rates last
|
||||||
const billableHeader = table.getByText('Billable Rate').first();
|
const billableHeader = table.getByRole('button', { name: /Billable Rate/ });
|
||||||
await billableHeader.click();
|
await billableHeader.click();
|
||||||
await expect(billableHeader.locator('svg')).toBeVisible();
|
await expect(billableHeader.locator('svg')).toBeVisible();
|
||||||
let names = await getTableRowNames(table);
|
let names = await getTableRowNames(table);
|
||||||
|
|||||||
14
e2e/utils/billing.ts
Normal file
14
e2e/utils/billing.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { PLAYWRIGHT_BASE_URL } from '../../playwright/config';
|
||||||
|
import type { TestContext } from './api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the trial of the test organization, only works with the Billing extension. Reload the page
|
||||||
|
* afterwards, the page props still contain the trial otherwise.
|
||||||
|
*/
|
||||||
|
export async function cancelTrialViaApi(ctx: TestContext): Promise<void> {
|
||||||
|
const response = await ctx.request.post(
|
||||||
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/trial/cancel`
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(204);
|
||||||
|
}
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"Billing": {
|
"Billing": {
|
||||||
"repository": "solidtime-io/extension-billing",
|
"repository": "solidtime-io/extension-billing",
|
||||||
"ref": "v0.0.9"
|
"ref": "v0.0.11"
|
||||||
},
|
},
|
||||||
"Services": {
|
"Services": {
|
||||||
"repository": "solidtime-io/extension-services",
|
"repository": "solidtime-io/extension-services",
|
||||||
"ref": "v0.0.4"
|
"ref": "v0.0.6"
|
||||||
},
|
},
|
||||||
"Invoicing": {
|
"Invoicing": {
|
||||||
"repository": "solidtime-io/extension-invoicing",
|
"repository": "solidtime-io/extension-invoicing",
|
||||||
"ref": "v0.0.10"
|
"ref": "v0.0.12"
|
||||||
},
|
},
|
||||||
"Auditing": {
|
"Auditing": {
|
||||||
"repository": "solidtime-io/extension-auditing",
|
"repository": "solidtime-io/extension-auditing",
|
||||||
"ref": "v0.0.4"
|
"ref": "v0.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
|||||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
|
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||||
@@ -31,6 +32,7 @@ return [
|
|||||||
TimeEntryStillRunningApiException::KEY => 'Time entry is still running',
|
TimeEntryStillRunningApiException::KEY => 'Time entry is still running',
|
||||||
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
|
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
|
||||||
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
|
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
|
||||||
|
TimeEntryMemberCanNotBeChangedWhileRunningApiException::KEY => 'The member of a running time entry can not be changed',
|
||||||
InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',
|
InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',
|
||||||
UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization',
|
UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization',
|
||||||
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',
|
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { enabledExtensions } from './playwright/config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read environment variables from file.
|
* Read environment variables from file.
|
||||||
@@ -7,23 +7,11 @@ import { existsSync, readFileSync } from 'node:fs';
|
|||||||
*/
|
*/
|
||||||
// require('dotenv').config();
|
// require('dotenv').config();
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* Extensions ship their e2e specs in extensions/<Name>/tests/e2e. Only the specs of extensions
|
* Extensions ship their e2e specs in extensions/<Name>/tests/e2e. Only the specs of extensions
|
||||||
* that are enabled in modules_statuses.json (php artisan module:enable <Name>) are collected,
|
* that are enabled in modules_statuses.json (php artisan module:enable <Name>) are collected,
|
||||||
* a checked out but disabled extension would fail its specs otherwise.
|
* a checked out but disabled extension would fail its specs otherwise.
|
||||||
*/
|
*
|
||||||
function enabledExtensions(): string[] {
|
|
||||||
const statusesFile = './modules_statuses.json';
|
|
||||||
if (!existsSync(statusesFile)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
|
|
||||||
return Object.entries(statuses)
|
|
||||||
.filter(([, enabled]) => enabled)
|
|
||||||
.map(([name]) => name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Every test root gets its own testDir (instead of testDir: '.') so playwright does not walk the
|
* Every test root gets its own testDir (instead of testDir: '.') so playwright does not walk the
|
||||||
* whole repository, including node_modules, vendor and nested worktrees, to find the specs.
|
* whole repository, including node_modules, vendor and nested worktrees, to find the specs.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
|
||||||
export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
|
export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
|
||||||
export const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';
|
export const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';
|
||||||
export const TEST_USER_PASSWORD = 'amazingpassword123';
|
export const TEST_USER_PASSWORD = 'amazingpassword123';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extensions that are enabled in modules_statuses.json (php artisan module:enable <Name>)
|
||||||
|
*/
|
||||||
|
export function enabledExtensions(): string[] {
|
||||||
|
const statusesFile = './modules_statuses.json';
|
||||||
|
if (!existsSync(statusesFile)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
|
||||||
|
return Object.entries(statuses)
|
||||||
|
.filter(([, enabled]) => enabled)
|
||||||
|
.map(([name]) => name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExtensionEnabled(name: string): boolean {
|
||||||
|
return enabledExtensions().includes(name);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,137 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import { CheckBadgeIcon, XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';
|
import { XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import {
|
import { isBillingActivated, isBlocked } from '@/utils/billing';
|
||||||
daysLeftInTrial,
|
|
||||||
isBillingActivated,
|
|
||||||
isBlocked,
|
|
||||||
isFreePlan,
|
|
||||||
isInTrial,
|
|
||||||
} from '@/utils/billing';
|
|
||||||
import { useSessionStorage } from '@vueuse/core';
|
import { useSessionStorage } from '@vueuse/core';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { canManageBilling } from '@/utils/permissions';
|
import { canManageBilling } from '@/utils/permissions';
|
||||||
|
|
||||||
const hideTrialBanner = useSessionStorage('showTrialBanner-' + getCurrentOrganizationId(), false);
|
|
||||||
const showTrialBanner = computed(() => isInTrial() && !hideTrialBanner.value);
|
|
||||||
const hideBlockedBanner = useSessionStorage(
|
const hideBlockedBanner = useSessionStorage(
|
||||||
'showBlockedBanner-' + getCurrentOrganizationId(),
|
'showBlockedBanner-' + getCurrentOrganizationId(),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value);
|
const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value);
|
||||||
const hideFreeUpgradeBanner = useSessionStorage(
|
|
||||||
'showFreeUpgradeBanner-' + getCurrentOrganizationId(),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
const showFreeUpgradeBanner = computed(
|
|
||||||
() =>
|
|
||||||
isFreePlan() && !isBlocked() && !hideFreeUpgradeBanner.value && !showBlackFridayBanner.value
|
|
||||||
);
|
|
||||||
const hideBlackFridayBanner = useSessionStorage(
|
|
||||||
'hideBlackFridayBanner-' + getCurrentOrganizationId(),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
const showBlackFridayBanner = computed(() => {
|
|
||||||
if (hideBlackFridayBanner.value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const today = new Date();
|
|
||||||
const blackFriday = new Date(2024, 10, 30);
|
|
||||||
return today < blackFriday;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
|
||||||
v-if="showBlackFridayBanner"
|
|
||||||
class="bg-tertiary text-xs lg:text-sm pb-1 pt-2 border-b border-border-secondary">
|
|
||||||
<MainContainer class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center space-x-1.5">
|
|
||||||
<svg class="w-4 mr-1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
fill="#FF37AD"
|
|
||||||
d="M22.498 68.97a11.845 11.845 0 1 0 0-23.687c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844m181.393-10.04a11.845 11.845 0 1 0-.003-23.688c-6.471.098-11.665 5.373-11.665 11.845c.001 6.472 5.197 11.745 11.668 11.842" />
|
|
||||||
<path
|
|
||||||
fill="#FCC954"
|
|
||||||
d="M213.503 211.097a11.845 11.845 0 1 0-.003-23.687c-6.471.098-11.665 5.373-11.664 11.845s5.196 11.745 11.667 11.842M70.872 23.689a11.845 11.845 0 1 0 0-23.688C64.4.1 59.206 5.373 59.206 11.845S64.4 23.591 70.872 23.689" />
|
|
||||||
<path
|
|
||||||
fill="#2890E9"
|
|
||||||
d="M140.945 105.94a9.25 9.25 0 0 1-8.974-11.484c.37-1.482.672-2.97.899-4.455a25.4 25.4 0 0 1-8.732 1.904c-5.379.205-10.195-.702-14.3-2.69a22.23 22.23 0 0 1-9.614-8.877c-4.415-7.652-4.034-17.718.964-25.645c4.765-7.568 12.836-11.664 21.586-10.995c6.74.527 12.647 3.051 17.378 7.382q1.293-3.647 2.473-7.803c4.833-17.058 6.429-34.187 6.442-34.36a9.24 9.24 0 0 1 10.041-8.37a9.25 9.25 0 0 1 8.37 10.044c-.067.767-1.768 19.03-7.068 37.735c-2.676 9.445-5.838 17.426-9.42 23.798q.396 2.13.631 4.372c.746 7.211.152 14.974-1.714 22.445a9.256 9.256 0 0 1-8.962 6.998m-20.123-43.827c-.956 0-2.64.28-3.996 2.43c-1.298 2.06-1.552 4.873-.588 6.544c1.282 2.223 5.054 2.417 7.19 2.336c2.424-.092 4.908-1.612 7.338-4.382a16 16 0 0 0-1.43-2.422c-2.007-2.787-4.547-4.212-7.998-4.482c-.13-.008-.305-.024-.516-.024" />
|
|
||||||
<path
|
|
||||||
fill="#F0A420"
|
|
||||||
d="M114.361 131.268c-38.343-30.224-78.42-43.319-89.514-29.246a12.8 12.8 0 0 0-2.257 4.509a4 4 0 0 0-.156.61v.024q-.223.947-.333 1.917L.393 236.18c-3.477 20.412 16.73 36.755 35.967 29.093l117.721-46.908c2.076-.826 7.185-3.982 8.583-5.724q.556-.544 1.037-1.153c11.092-14.075-11-49.988-49.34-80.223z" />
|
|
||||||
<path
|
|
||||||
fill="#FCC954"
|
|
||||||
d="M163.688 211.494c11.1-14.08-10.984-50-49.327-80.226c-38.343-30.227-78.425-43.316-89.524-29.236s10.983 50 49.326 80.226c38.343 30.227 78.425 43.316 89.525 29.236" />
|
|
||||||
<path
|
|
||||||
fill="#F0A420"
|
|
||||||
d="M156.994 203.294c9.108-11.556-10.956-42.563-44.817-69.256c-33.861-26.695-68.697-38.966-77.804-27.413c-9.11 11.556 10.954 42.563 44.815 69.256c33.86 26.695 68.697 38.969 77.806 27.413" />
|
|
||||||
<path
|
|
||||||
fill="#2E6AC9"
|
|
||||||
d="M76.059 249.456c-14.327.07-26.004-7.101-40.158-18.257C19.431 218.21 8.493 202.665 7.63 193.81l-4.668 27.327c2.16 7.798 9.523 17.683 20.202 26.101c8.883 7.004 17.844 11.813 27.135 12.48l25.76-10.266zm-14.332-49.6c-27.443-21.637-45.271-46.467-44.77-60.669l-4.549 26.63c.351 12.685 15.175 33.184 36.262 49.808c18.894 14.896 38.583 25.38 53.66 23.363l25.593-10.2c-20.62 1.425-42.376-10.147-66.196-28.931" />
|
|
||||||
<path
|
|
||||||
fill="#2890E9"
|
|
||||||
d="M118.535 145.052a11.845 11.845 0 1 0 0-23.688c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844" />
|
|
||||||
<path
|
|
||||||
fill="#FF37AD"
|
|
||||||
d="m182.412 122.007l.087-.097c.108-.116.308-.33.596-.621a45 45 0 0 1 2.8-2.56c3.56-2.98 7.45-5.54 11.594-7.63c10.128-5.125 25.208-9.307 44.985-4.747c5.943 1.37 11.87-2.336 13.241-8.278c1.37-5.942-2.336-11.87-8.278-13.24c-25.602-5.903-45.957-.506-59.922 6.566a82.5 82.5 0 0 0-15.857 10.449a66 66 0 0 0-4.215 3.866a45 45 0 0 0-1.53 1.615l-.12.135l-.042.048l-.02.022l-.007.008c-.003.005-.009.01 8.361 7.21l-8.37-7.2c-3.877 4.622-3.328 11.5 1.233 15.448s11.446 3.506 15.464-.994M73.03 43.248a11.75 11.75 0 0 0-16.23-3.664a11.76 11.76 0 0 0-3.665 16.227c.427.683 9.178 14.86 10.976 34.276c1.83 19.727-3.966 37.86-17.253 54.12c4.474 5.686 9.858 11.596 16.008 17.507c8.51-9.834 14.913-20.402 19.12-31.583c5.175-13.756 7.006-28.342 5.445-43.348c-2.487-23.874-12.874-41.11-14.402-43.535" />
|
|
||||||
<path
|
|
||||||
fill="#2890E9"
|
|
||||||
d="M220.242 156.578c6.002 1.553 10.244 3.246 12.077 4.034a11.86 11.86 0 0 0 13.94-1.12a11.87 11.87 0 0 0 4.107-8.765a11.85 11.85 0 0 0-8.06-11.426c-5.618-2.495-26.905-10.92-55.044-9.423c-18.941 1.007-37.155 6.253-54.133 15.608c-16.076 8.86-31.004 21.412-44.556 37.425a199 199 0 0 0 20.17 12.607c22.882-26.08 49.283-40.217 78.7-42.085a105.9 105.9 0 0 1 32.8 3.145" />
|
|
||||||
</svg>
|
|
||||||
<div class="flex-1 space-x-1">
|
|
||||||
<span class="font-medium">
|
|
||||||
<strong>BLACK FRIDAY SALE!</strong> Use the code
|
|
||||||
<strong>BLACKFRIDAY</strong> at checkout and get
|
|
||||||
<strong>30% off</strong> the solidtime yearly plan.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Link v-if="canManageBilling()" href="/billing">
|
|
||||||
<div
|
|
||||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
|
||||||
<span>Upgrade now</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<button class="p-1" @click="hideBlackFridayBanner = true">
|
|
||||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</MainContainer>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="showTrialBanner"
|
|
||||||
class="bg-accent-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
|
||||||
<MainContainer class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center space-x-1.5">
|
|
||||||
<CheckBadgeIcon class="w-4 text-text-primary/50"></CheckBadgeIcon>
|
|
||||||
<div class="flex-1 space-x-1">
|
|
||||||
<span class="font-medium">
|
|
||||||
Your trial expires in {{ daysLeftInTrial() }} days.
|
|
||||||
</span>
|
|
||||||
<span class="hidden md:inline">
|
|
||||||
To continue using all features & support the development of solidtime,
|
|
||||||
please upgrade your plan.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Link v-if="canManageBilling()" href="/billing">
|
|
||||||
<div
|
|
||||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
|
||||||
<span>Upgrade now</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<button class="p-1" @click="hideTrialBanner = true">
|
|
||||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</MainContainer>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="showBlockedBanner"
|
v-if="showBlockedBanner"
|
||||||
class="bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
class="bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
||||||
@@ -159,33 +43,6 @@ const showBlackFridayBanner = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-if="showFreeUpgradeBanner"
|
|
||||||
class="bg-tertiary text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
|
||||||
<MainContainer class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center space-x-1.5">
|
|
||||||
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
|
|
||||||
<div class="flex-1 space-x-1">
|
|
||||||
<span class="font-medium"> You are currently using the Free Plan. </span>
|
|
||||||
<span class="hidden md:inline">
|
|
||||||
To unlock all premium features & support the development of solidtime,
|
|
||||||
please upgrade your plan.</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
|
|
||||||
<div
|
|
||||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
|
||||||
<span>Upgrade now</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<button class="p-1" @click="hideFreeUpgradeBanner = true">
|
|
||||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</MainContainer>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
86
resources/js/Components/Billing/CancelTrialModal.vue
Normal file
86
resources/js/Components/Billing/CancelTrialModal.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { router } from '@inertiajs/vue3';
|
||||||
|
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||||
|
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
|
||||||
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
|
import { XMarkIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
import { api } from '@/packages/api/src';
|
||||||
|
|
||||||
|
const show = defineModel<boolean>('show', { default: false });
|
||||||
|
|
||||||
|
const processing = ref(false);
|
||||||
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
|
|
||||||
|
const lostFeatures = [
|
||||||
|
'Billable hours & rates',
|
||||||
|
'Invoicing',
|
||||||
|
'Project & task estimates',
|
||||||
|
'Rounding of time entries',
|
||||||
|
'PDF reports & shareable reports',
|
||||||
|
'Team support & access management',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function cancelTrial() {
|
||||||
|
// Note: The organization of this page, the current organization on the server can differ if the user switched organizations in another tab
|
||||||
|
const organizationId = getCurrentOrganizationId();
|
||||||
|
if (processing.value || organizationId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
processing.value = true;
|
||||||
|
try {
|
||||||
|
await handleApiRequestNotifications(
|
||||||
|
() => api.cancelTrial(undefined, { params: { organization: organizationId } }),
|
||||||
|
'Your trial has been canceled',
|
||||||
|
'Your trial could not be canceled, please try again',
|
||||||
|
() => {
|
||||||
|
show.value = false;
|
||||||
|
// Reloads the shared billing props of the page, they still contain the trial otherwise
|
||||||
|
router.reload();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
processing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal :show="show" closeable @close="show = false">
|
||||||
|
<template #title>Cancel trial</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<p class="text-sm text-text-secondary">
|
||||||
|
Your organization switches to the Free plan right away. You will lose access to
|
||||||
|
these Professional features:
|
||||||
|
</p>
|
||||||
|
<ul class="mt-4 grid gap-2 sm:grid-cols-2">
|
||||||
|
<li
|
||||||
|
v-for="feature in lostFeatures"
|
||||||
|
:key="feature"
|
||||||
|
class="flex items-center gap-2 text-sm text-text-primary">
|
||||||
|
<XMarkIcon class="h-4 w-4 shrink-0 text-red-500/80" />
|
||||||
|
{{ feature }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mt-5 text-sm text-text-secondary">
|
||||||
|
Organizations with more than one member are blocked until you upgrade or remove
|
||||||
|
everyone except the owner.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton @click="show = false">Keep trial</SecondaryButton>
|
||||||
|
<DangerButton
|
||||||
|
class="ms-3"
|
||||||
|
data-testid="confirm_cancel_trial"
|
||||||
|
:class="{ 'opacity-25': processing }"
|
||||||
|
:disabled="processing"
|
||||||
|
@click="cancelTrial">
|
||||||
|
Cancel trial
|
||||||
|
</DangerButton>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
56
resources/js/Components/Billing/SidebarPlanStatus.vue
Normal file
56
resources/js/Components/Billing/SidebarPlanStatus.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { router } from '@inertiajs/vue3';
|
||||||
|
import { SparklesIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
|
import TrialUpgradeModal from '@/Components/Billing/TrialUpgradeModal.vue';
|
||||||
|
import CancelTrialModal from '@/Components/Billing/CancelTrialModal.vue';
|
||||||
|
import { daysLeftInTrial, isBlocked, isFreePlan, isInTrial } from '@/utils/billing';
|
||||||
|
|
||||||
|
const showTrialUpgradeModal = ref(false);
|
||||||
|
const showCancelTrialModal = ref(false);
|
||||||
|
|
||||||
|
const daysLeftLabel = computed(() => {
|
||||||
|
const days = daysLeftInTrial();
|
||||||
|
return days === 1 ? '1 day left' : `${days} days left`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function openBilling() {
|
||||||
|
router.visit('/billing');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isInTrial() || (isFreePlan() && !isBlocked())" class="pb-3">
|
||||||
|
<Button
|
||||||
|
v-if="isInTrial()"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
data-testid="sidebar_trial_days_left"
|
||||||
|
class="w-full justify-between rounded-lg px-2.5 text-xs font-normal [&_svg]:size-3.5"
|
||||||
|
@click="showTrialUpgradeModal = true">
|
||||||
|
<span class="flex items-center gap-1.5 font-medium text-text-primary">
|
||||||
|
<SparklesIcon class="h-3.5 w-3.5 text-accent-500 dark:text-accent-300" />
|
||||||
|
Professional trial
|
||||||
|
</span>
|
||||||
|
<span class="text-text-secondary">{{ daysLeftLabel }}</span>
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
v-else-if="isFreePlan() && !isBlocked()"
|
||||||
|
class="flex items-center justify-between gap-2 rounded-lg border border-border-secondary bg-card-background py-1.5 pl-2.5 pr-1.5 text-xs">
|
||||||
|
<span class="font-medium text-text-primary">Free plan</span>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
data-testid="sidebar_upgrade_button"
|
||||||
|
class="h-6 bg-accent-500 px-2.5 font-semibold text-white hover:bg-accent-400"
|
||||||
|
@click="openBilling">
|
||||||
|
Upgrade
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TrialUpgradeModal
|
||||||
|
v-model:show="showTrialUpgradeModal"
|
||||||
|
@cancel-trial="showCancelTrialModal = true" />
|
||||||
|
<CancelTrialModal v-model:show="showCancelTrialModal" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
79
resources/js/Components/Billing/TrialUpgradeModal.vue
Normal file
79
resources/js/Components/Billing/TrialUpgradeModal.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/packages/ui/src/dialog';
|
||||||
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
|
import { SparklesIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import { featureIcons } from '@/packages/ui/src/UpgradeModal/featureIcons';
|
||||||
|
import { professionalFeatures, useUpgradeCta } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
|
||||||
|
import { daysLeftInTrial } from '@/utils/billing';
|
||||||
|
|
||||||
|
const show = defineModel<boolean>('show', { default: false });
|
||||||
|
|
||||||
|
const emit = defineEmits<{ cancelTrial: [] }>();
|
||||||
|
|
||||||
|
const { goToBilling } = useUpgradeCta();
|
||||||
|
|
||||||
|
const daysLeftLabel = computed(() => {
|
||||||
|
const days = daysLeftInTrial();
|
||||||
|
return days === 1 ? '1 day' : `${days} days`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function cancelTrial() {
|
||||||
|
show.value = false;
|
||||||
|
emit('cancelTrial');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="show">
|
||||||
|
<DialogContent class="sm:max-w-xl p-0 overflow-hidden">
|
||||||
|
<div class="px-8 pt-8 text-center">
|
||||||
|
<div
|
||||||
|
class="mx-auto inline-flex items-center gap-1.5 rounded-full border border-border-secondary bg-card-background px-3 py-1 text-xs font-medium text-text-primary">
|
||||||
|
<SparklesIcon class="h-3.5 w-3.5 text-accent-500 dark:text-accent-300" />
|
||||||
|
{{ daysLeftLabel }} left in your trial
|
||||||
|
</div>
|
||||||
|
<DialogTitle class="mt-4 text-xl font-semibold tracking-tight text-text-primary">
|
||||||
|
Keep solidtime Professional
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription class="mt-1.5 text-sm text-text-secondary">
|
||||||
|
Upgrade now to keep these features after your trial ends.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-x-6 gap-y-4 px-8 pt-8">
|
||||||
|
<div v-for="item in professionalFeatures" :key="item.key" class="flex gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border-tertiary bg-card-background">
|
||||||
|
<component
|
||||||
|
:is="featureIcons[item.key]"
|
||||||
|
class="h-3.5 w-3.5 text-text-secondary" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-text-primary">{{ item.title }}</p>
|
||||||
|
<p class="mt-0.5 text-xs leading-relaxed text-text-tertiary">
|
||||||
|
{{ item.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto flex max-w-xs flex-col items-center gap-3 px-8 pt-8 pb-6">
|
||||||
|
<Button
|
||||||
|
data-testid="trial_upgrade_modal_cta"
|
||||||
|
class="w-full bg-accent-500 px-4 font-semibold text-white shadow-[0_0_24px_-6px] shadow-accent-500/60 hover:bg-accent-400"
|
||||||
|
@click="goToBilling">
|
||||||
|
Upgrade to Professional
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
data-testid="trial_upgrade_modal_cancel_trial"
|
||||||
|
class="font-normal text-text-tertiary hover:text-text-primary"
|
||||||
|
@click="cancelTrial">
|
||||||
|
Cancel trial
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -29,6 +29,7 @@ import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue'
|
|||||||
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
|
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
|
||||||
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
||||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
||||||
|
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||||
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
|
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
|
||||||
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ const {
|
|||||||
showCreateTaskModal,
|
showCreateTaskModal,
|
||||||
showCreateTagModal,
|
showCreateTagModal,
|
||||||
showCreateGoalModal,
|
showCreateGoalModal,
|
||||||
|
showGoalUpgradeModal,
|
||||||
showInviteMemberModal,
|
showInviteMemberModal,
|
||||||
showCreateTimeEntryModal,
|
showCreateTimeEntryModal,
|
||||||
showProjectSelector,
|
showProjectSelector,
|
||||||
@@ -185,6 +187,7 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
|
|||||||
|
|
||||||
<!-- Goal Create Modal -->
|
<!-- Goal Create Modal -->
|
||||||
<GoalCreateModal v-model:show="showCreateGoalModal" />
|
<GoalCreateModal v-model:show="showCreateGoalModal" />
|
||||||
|
<UpgradeModal v-model:show="showGoalUpgradeModal" feature="goals" />
|
||||||
|
|
||||||
<!-- Member Invite Modal -->
|
<!-- Member Invite Modal -->
|
||||||
<MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" />
|
<MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" />
|
||||||
|
|||||||
43
resources/js/Components/Common/Goal/GoalCreateButton.test.ts
Normal file
43
resources/js/Components/Common/Goal/GoalCreateButton.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import GoalCreateButton from './GoalCreateButton.vue';
|
||||||
|
|
||||||
|
function mountGoalCreateButton(canCreateMoreGoals: boolean) {
|
||||||
|
return mount(GoalCreateButton, {
|
||||||
|
props: { canCreateMoreGoals },
|
||||||
|
slots: { default: 'Create your first goal' },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
GoalCreateModal: { name: 'GoalCreateModal', props: ['show'], template: '<div />' },
|
||||||
|
UpgradeModal: {
|
||||||
|
name: 'UpgradeModal',
|
||||||
|
props: ['show', 'feature'],
|
||||||
|
template: '<div />',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GoalCreateButton', () => {
|
||||||
|
it('opens the create modal while more goals can be created', async () => {
|
||||||
|
const wrapper = mountGoalCreateButton(true);
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.getComponent({ name: 'GoalCreateModal' }).props('show')).toBe(true);
|
||||||
|
expect(wrapper.getComponent({ name: 'UpgradeModal' }).props('show')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the upgrade modal once the goal limit is reached', async () => {
|
||||||
|
const wrapper = mountGoalCreateButton(false);
|
||||||
|
|
||||||
|
expect(wrapper.get('button').text()).toBe('Create your first goal');
|
||||||
|
await wrapper.get('button').trigger('click');
|
||||||
|
|
||||||
|
const upgradeModal = wrapper.getComponent({ name: 'UpgradeModal' });
|
||||||
|
expect(upgradeModal.props('show')).toBe(true);
|
||||||
|
expect(upgradeModal.props('feature')).toBe('goals');
|
||||||
|
expect(wrapper.getComponent({ name: 'GoalCreateModal' }).props('show')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
resources/js/Components/Common/Goal/GoalCreateButton.vue
Normal file
31
resources/js/Components/Common/Goal/GoalCreateButton.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
|
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||||
|
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
||||||
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
canCreateMoreGoals: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const showCreateGoalModal = ref(false);
|
||||||
|
const showUpgradeModal = ref(false);
|
||||||
|
|
||||||
|
// The button stays visible when the goal limit is reached and explains the limit instead
|
||||||
|
function onClick() {
|
||||||
|
if (props.canCreateMoreGoals) {
|
||||||
|
showCreateGoalModal.value = true;
|
||||||
|
} else {
|
||||||
|
showUpgradeModal.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
|
||||||
|
<UpgradeModal v-model:show="showUpgradeModal" feature="goals" />
|
||||||
|
<SecondaryButton :icon="PlusIcon" @click="onClick">
|
||||||
|
<slot>Create Goal</slot>
|
||||||
|
</SecondaryButton>
|
||||||
|
</template>
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
|
||||||
import { Target } from '@lucide/vue';
|
import { Target } from '@lucide/vue';
|
||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import type { Goal } from '@/packages/api/src';
|
import type { Goal } from '@/packages/api/src';
|
||||||
import GoalTableRow from '@/Components/Common/Goal/GoalTableRow.vue';
|
import GoalTableRow from '@/Components/Common/Goal/GoalTableRow.vue';
|
||||||
import GoalTableHeading from '@/Components/Common/Goal/GoalTableHeading.vue';
|
import GoalTableHeading from '@/Components/Common/Goal/GoalTableHeading.vue';
|
||||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.vue';
|
||||||
import { canCreateGoals } from '@/utils/permissions';
|
import { canCreateGoals } from '@/utils/permissions';
|
||||||
import { LoadingSpinner } from '@/packages/ui/src';
|
import { LoadingSpinner } from '@/packages/ui/src';
|
||||||
import { isGoalsExtensionActivated } from '@/utils/billing';
|
import { isGoalsExtensionActivated } from '@/utils/billing';
|
||||||
@@ -18,7 +15,6 @@ const props = defineProps<{
|
|||||||
showingArchived: boolean;
|
showingArchived: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showCreateGoalModal = ref(false);
|
|
||||||
const showTargetColumn = isGoalsExtensionActivated();
|
const showTargetColumn = isGoalsExtensionActivated();
|
||||||
// Every column gets a share of the free space so the columns are spread evenly over the table
|
// Every column gets a share of the free space so the columns are spread evenly over the table
|
||||||
const gridTemplateColumns = [
|
const gridTemplateColumns = [
|
||||||
@@ -33,7 +29,6 @@ const gridTemplateColumns = [
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
|
|
||||||
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
||||||
<div class="inline-block min-w-full align-middle">
|
<div class="inline-block min-w-full align-middle">
|
||||||
<div data-testid="goal_table" class="grid min-w-full" :style="{ gridTemplateColumns }">
|
<div data-testid="goal_table" class="grid min-w-full" :style="{ gridTemplateColumns }">
|
||||||
@@ -51,14 +46,11 @@ const gridTemplateColumns = [
|
|||||||
<p v-if="canCreateGoals() && !props.showingArchived" class="pb-5">
|
<p v-if="canCreateGoals() && !props.showingArchived" class="pb-5">
|
||||||
Set yourself a target and track your progress every day, week or month.
|
Set yourself a target and track your progress every day, week or month.
|
||||||
</p>
|
</p>
|
||||||
<SecondaryButton
|
<GoalCreateButton
|
||||||
v-if="
|
v-if="canCreateGoals() && !props.showingArchived"
|
||||||
canCreateGoals() && !props.showingArchived && props.canCreateMoreGoals
|
:can-create-more-goals="props.canCreateMoreGoals"
|
||||||
"
|
|
||||||
:icon="PlusIcon"
|
|
||||||
@click="showCreateGoalModal = true"
|
|
||||||
>Create your first goal
|
>Create your first goal
|
||||||
</SecondaryButton>
|
</GoalCreateButton>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="goal in props.goals" :key="goal.id">
|
<template v-for="goal in props.goals" :key="goal.id">
|
||||||
<GoalTableRow
|
<GoalTableRow
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const deleteMutation = useMutation({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
close();
|
close();
|
||||||
queryClient.invalidateQueries({ queryKey: ['members'] });
|
queryClient.invalidateQueries({ queryKey: ['members'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { getOrganizationCurrencyString } from '@/utils/money';
|
|||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
|
|
||||||
const { updateMember } = useMembersStore();
|
const { updateMember } = useMembersStore();
|
||||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||||
@@ -71,6 +72,8 @@ function saveWithChecks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
|
|
||||||
const billableRateSelect = ref<MemberBillableKey>('default-rate');
|
const billableRateSelect = ref<MemberBillableKey>('default-rate');
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -159,7 +162,12 @@ const roleDescription = computed(() => {
|
|||||||
<FieldLabel :icon="BillableIcon" for="billableRateType"
|
<FieldLabel :icon="BillableIcon" for="billableRateType"
|
||||||
>Billable Rate</FieldLabel
|
>Billable Rate</FieldLabel
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<BillableRateInput
|
||||||
|
v-if="billableRatesLocked"
|
||||||
|
v-model="displayedRate"
|
||||||
|
:currency="getOrganizationCurrencyString()"
|
||||||
|
name="memberBillableRate" />
|
||||||
|
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
<Select v-model="billableRateSelect">
|
<Select v-model="billableRateSelect">
|
||||||
<SelectTrigger id="billableRateType">
|
<SelectTrigger id="billableRateType">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ async function submit() {
|
|||||||
'There was an error merging the members.',
|
'There was an error merging the members.',
|
||||||
() => {
|
() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['members'] });
|
queryClient.invalidateQueries({ queryKey: ['members'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||||
show.value = false;
|
show.value = false;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import type { Member, Organization } from '@/packages/api/src';
|
import type { Member, Organization } from '@/packages/api/src';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
|
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
|
||||||
@@ -25,6 +26,7 @@ import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
|
|||||||
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
|
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
|
||||||
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
|
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
|
||||||
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
|
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
|
||||||
|
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||||
import { capitalizeFirstLetter } from '../../../utils/format';
|
import { capitalizeFirstLetter } from '../../../utils/format';
|
||||||
import { formatCents } from '../../../packages/ui/src/utils/money';
|
import { formatCents } from '../../../packages/ui/src/utils/money';
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +73,8 @@ async function invitePlaceholder(id: string) {
|
|||||||
const userHasValidMailAddress = computed(() => {
|
const userHasValidMailAddress = computed(() => {
|
||||||
return !props.member.email.endsWith('@solidtime-import.test');
|
return !props.member.email.endsWith('@solidtime-import.test');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -89,8 +93,9 @@ const userHasValidMailAddress = computed(() => {
|
|||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||||
{{ capitalizeFirstLetter(member.role) }}
|
{{ capitalizeFirstLetter(member.role) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
||||||
<span v-if="member.billable_rate">
|
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||||
|
<span v-else-if="member.billable_rate">
|
||||||
{{
|
{{
|
||||||
formatCents(
|
formatCents(
|
||||||
member.billable_rate,
|
member.billable_rate,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
|
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
|
||||||
import type { Project } from '@/packages/api/src';
|
import type { Project } from '@/packages/api/src';
|
||||||
import { computed, ref, inject, type ComputedRef } from 'vue';
|
import { computed, ref, inject, type ComputedRef } from 'vue';
|
||||||
@@ -15,10 +16,11 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
|
|||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
import TableRow from '@/Components/TableRow.vue';
|
import TableRow from '@/Components/TableRow.vue';
|
||||||
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
|
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
|
||||||
|
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
||||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
|
||||||
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { canUpdateProjects, canDeleteProjects } from '@/utils/permissions';
|
import { canUpdateProjects, canDeleteProjects } from '@/utils/permissions';
|
||||||
@@ -78,6 +80,8 @@ const billableRateInfo = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showEditProjectModal = ref(false);
|
const showEditProjectModal = ref(false);
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -100,13 +104,15 @@ const showEditProjectModal = ref(false);
|
|||||||
</span>
|
</span>
|
||||||
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
|
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
|
<div
|
||||||
|
class="whitespace-nowrap min-w-0 px-3 py-4 flex items-center text-sm text-text-primary">
|
||||||
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
||||||
{{ client?.name }}
|
{{ client?.name }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-text-tertiary">No client</div>
|
<div v-else class="text-text-tertiary">No client</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
<div
|
||||||
|
class="whitespace-nowrap px-3 py-4 flex items-center text-sm text-text-primary">
|
||||||
<div v-if="project.spent_time">
|
<div v-if="project.spent_time">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
@@ -119,7 +125,9 @@ const showEditProjectModal = ref(false);
|
|||||||
<div v-else class="text-text-tertiary">--</div>
|
<div v-else class="text-text-tertiary">--</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
||||||
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
<UpgradeBadge
|
||||||
|
v-if="!isAllowedToPerformPremiumAction()"
|
||||||
|
feature="estimates"></UpgradeBadge>
|
||||||
<EstimatedTimeProgress
|
<EstimatedTimeProgress
|
||||||
v-else-if="project.estimated_time"
|
v-else-if="project.estimated_time"
|
||||||
:estimated="project.estimated_time"
|
:estimated="project.estimated_time"
|
||||||
@@ -128,8 +136,9 @@ const showEditProjectModal = ref(false);
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showBillableRate"
|
v-if="showBillableRate"
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
||||||
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
|
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||||
|
<span v-else-if="billableRateInfo">{{ billableRateInfo }}</span>
|
||||||
<span v-else class="text-text-tertiary">--</span>
|
<span v-else class="text-text-tertiary">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import type { ProjectMember } from '@/packages/api/src';
|
import type { ProjectMember } from '@/packages/api/src';
|
||||||
import { computed, ref, inject, type ComputedRef } from 'vue';
|
import { computed, ref, inject, type ComputedRef } from 'vue';
|
||||||
import TableRow from '@/Components/TableRow.vue';
|
import TableRow from '@/Components/TableRow.vue';
|
||||||
@@ -10,6 +11,7 @@ import { capitalizeFirstLetter } from '@/utils/format';
|
|||||||
import ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue';
|
import ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import type { Organization } from '@/packages/api/src';
|
import type { Organization } from '@/packages/api/src';
|
||||||
|
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projectMember: ProjectMember;
|
projectMember: ProjectMember;
|
||||||
@@ -33,6 +35,8 @@ const member = computed(() => {
|
|||||||
return members.value.find((member) => member.id === props.projectMember.member_id);
|
return members.value.find((member) => member.id === props.projectMember.member_id);
|
||||||
});
|
});
|
||||||
const showEditModal = ref(false);
|
const showEditModal = ref(false);
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -47,18 +51,21 @@ const showEditModal = ref(false);
|
|||||||
{{ member?.name }}
|
{{ member?.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
||||||
{{
|
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||||
projectMember.billable_rate
|
<span v-else>
|
||||||
? formatCents(
|
{{
|
||||||
projectMember.billable_rate,
|
projectMember.billable_rate
|
||||||
getOrganizationCurrencyString(),
|
? formatCents(
|
||||||
organization?.currency_format,
|
projectMember.billable_rate,
|
||||||
organization?.currency_symbol,
|
getOrganizationCurrencyString(),
|
||||||
organization?.number_format
|
organization?.currency_format,
|
||||||
)
|
organization?.currency_symbol,
|
||||||
: '--'
|
organization?.number_format
|
||||||
}}
|
)
|
||||||
|
: '--'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
||||||
{{ capitalizeFirstLetter(member?.role ?? '') }}
|
{{ capitalizeFirstLetter(member?.role ?? '') }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue'
|
|||||||
import { h, ref } from 'vue';
|
import { h, ref } from 'vue';
|
||||||
import type { CreateReportBodyProperties } from '@/packages/api/src';
|
import type { CreateReportBodyProperties } from '@/packages/api/src';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||||
import { canCreateReports } from '@/utils/permissions';
|
import { canCreateReports } from '@/utils/permissions';
|
||||||
defineProps<{
|
defineProps<{
|
||||||
reportProperties: CreateReportBodyProperties;
|
reportProperties: CreateReportBodyProperties;
|
||||||
@@ -30,9 +30,7 @@ function onSaveReportClick() {
|
|||||||
<ReportCreateModal
|
<ReportCreateModal
|
||||||
v-model:show="showCreateReportModal"
|
v-model:show="showCreateReportModal"
|
||||||
:properties="reportProperties"></ReportCreateModal>
|
:properties="reportProperties"></ReportCreateModal>
|
||||||
<UpgradeModal v-model:show="showPremiumModal">
|
<UpgradeModal v-model:show="showPremiumModal" feature="shareable-reports" />
|
||||||
<strong>Sharable Reports</strong> is only available in solidtime Professional.
|
|
||||||
</UpgradeModal>
|
|
||||||
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick"
|
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick"
|
||||||
>Save Report</SecondaryButton
|
>Save Report</SecondaryButton
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import type { ExportFormat } from '@/types/reporting';
|
import type { ExportFormat } from '@/types/reporting';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
download: (format: ExportFormat) => Promise<void>;
|
download: (format: ExportFormat) => Promise<void>;
|
||||||
@@ -48,9 +48,7 @@ function triggerDownload(format: ExportFormat) {
|
|||||||
<DropdownMenuItem @click="triggerDownload('ods')"> Export as ODS </DropdownMenuItem>
|
<DropdownMenuItem @click="triggerDownload('ods')"> Export as ODS </DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<UpgradeModal v-model:show="showPremiumModal">
|
<UpgradeModal v-model:show="showPremiumModal" feature="pdf-reports" />
|
||||||
<strong>PDF Reports</strong> are only available in solidtime Professional.
|
|
||||||
</UpgradeModal>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
||||||
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
import { CheckCircleIcon, LockClosedIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||||
import { FolderIcon } from '@heroicons/vue/16/solid';
|
import { FolderIcon } from '@heroicons/vue/16/solid';
|
||||||
import { Check, Coffee } from '@lucide/vue';
|
import { Check, Coffee } from '@lucide/vue';
|
||||||
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
|
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
||||||
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
||||||
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
|
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
|
||||||
@@ -12,6 +13,7 @@ import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultisel
|
|||||||
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
|
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
|
||||||
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
|
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
|
||||||
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
@@ -35,6 +37,8 @@ const roundingMinutes = defineModel<number>('roundingMinutes', { required: true
|
|||||||
const startDate = defineModel<string>('startDate', { required: true });
|
const startDate = defineModel<string>('startDate', { required: true });
|
||||||
const endDate = defineModel<string>('endDate', { required: true });
|
const endDate = defineModel<string>('endDate', { required: true });
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
submit: [];
|
submit: [];
|
||||||
}>();
|
}>();
|
||||||
@@ -141,7 +145,17 @@ async function createTag(name: string) {
|
|||||||
</template>
|
</template>
|
||||||
</TagDropdown>
|
</TagDropdown>
|
||||||
|
|
||||||
<Select v-model="billable" @update:model-value="emit('submit')">
|
<Button
|
||||||
|
v-if="billableRatesLocked"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
data-testid="billable_filter_locked"
|
||||||
|
@click="requestUpgrade">
|
||||||
|
<BillableIcon class="h-4 text-text-quaternary" />
|
||||||
|
<span class="text-text-secondary">Billable</span>
|
||||||
|
<LockClosedIcon class="!w-3 text-text-tertiary" />
|
||||||
|
</Button>
|
||||||
|
<Select v-else v-model="billable" @update:model-value="emit('submit')">
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import {
|
import {
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
@@ -30,7 +32,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/packages/ui/src';
|
} from '@/packages/ui/src';
|
||||||
import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';
|
import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';
|
||||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||||
|
import type { UpgradeFeature } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
|
||||||
import { canCreateReports } from '@/utils/permissions';
|
import { canCreateReports } from '@/utils/permissions';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { computed, type ComputedRef, inject, ref, watch } from 'vue';
|
import { computed, type ComputedRef, inject, ref, watch } from 'vue';
|
||||||
@@ -214,10 +217,13 @@ const showExportModal = ref(false);
|
|||||||
const exportUrl = ref<string | null>(null);
|
const exportUrl = ref<string | null>(null);
|
||||||
const showCreateReportModal = ref(false);
|
const showCreateReportModal = ref(false);
|
||||||
const showPremiumModal = ref(false);
|
const showPremiumModal = ref(false);
|
||||||
|
// Note: The premium modal is shared by the PDF export and saving a report
|
||||||
|
const premiumFeature = ref<UpgradeFeature>('pdf-reports');
|
||||||
const exportLoading = ref(false);
|
const exportLoading = ref(false);
|
||||||
|
|
||||||
function triggerExport(format: ExportFormat) {
|
function triggerExport(format: ExportFormat) {
|
||||||
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
|
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
|
||||||
|
premiumFeature.value = 'pdf-reports';
|
||||||
showPremiumModal.value = true;
|
showPremiumModal.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -231,6 +237,7 @@ function onSaveReportClick() {
|
|||||||
if (isAllowedToPerformPremiumAction()) {
|
if (isAllowedToPerformPremiumAction()) {
|
||||||
showCreateReportModal.value = true;
|
showCreateReportModal.value = true;
|
||||||
} else {
|
} else {
|
||||||
|
premiumFeature.value = 'shareable-reports';
|
||||||
showPremiumModal.value = true;
|
showPremiumModal.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +297,8 @@ const tableData = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -299,9 +308,7 @@ const tableData = computed(() => {
|
|||||||
<ReportCreateModal
|
<ReportCreateModal
|
||||||
v-model:show="showCreateReportModal"
|
v-model:show="showCreateReportModal"
|
||||||
:properties="reportProperties"></ReportCreateModal>
|
:properties="reportProperties"></ReportCreateModal>
|
||||||
<UpgradeModal v-model:show="showPremiumModal">
|
<UpgradeModal v-model:show="showPremiumModal" :feature="premiumFeature" />
|
||||||
This feature is only available in solidtime Professional.
|
|
||||||
</UpgradeModal>
|
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
|
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
|
||||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||||
@@ -447,17 +454,20 @@ const tableData = computed(() => {
|
|||||||
<div
|
<div
|
||||||
v-if="showBillableRate"
|
v-if="showBillableRate"
|
||||||
class="justify-end pr-6 flex items-center font-medium">
|
class="justify-end pr-6 flex items-center font-medium">
|
||||||
{{
|
<UpgradeLockedValue v-if="billableRatesLocked" />
|
||||||
aggregatedTableTimeEntries.cost
|
<template v-else>
|
||||||
? formatCents(
|
{{
|
||||||
aggregatedTableTimeEntries.cost,
|
aggregatedTableTimeEntries.cost
|
||||||
getOrganizationCurrencyString(),
|
? formatCents(
|
||||||
organization?.currency_format,
|
aggregatedTableTimeEntries.cost,
|
||||||
organization?.currency_symbol,
|
getOrganizationCurrencyString(),
|
||||||
organization?.number_format
|
organization?.currency_format,
|
||||||
)
|
organization?.currency_symbol,
|
||||||
: '--'
|
organization?.number_format
|
||||||
}}
|
)
|
||||||
|
: '--'
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
|
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
||||||
|
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import { ref, inject, type ComputedRef } from 'vue';
|
import { ref, inject, type ComputedRef } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import type { Organization } from '@/packages/api/src';
|
import type { Organization } from '@/packages/api/src';
|
||||||
@@ -27,6 +29,8 @@ const props = defineProps<{
|
|||||||
const expanded = ref(false);
|
const expanded = ref(false);
|
||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -53,17 +57,20 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
|||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showCost" class="justify-end pr-6 flex items-center">
|
<div v-if="showCost" class="justify-end pr-6 flex items-center">
|
||||||
{{
|
<UpgradeLockedValue v-if="billableRatesLocked" />
|
||||||
entry.cost
|
<template v-else>
|
||||||
? formatCents(
|
{{
|
||||||
entry.cost,
|
entry.cost
|
||||||
props.currency,
|
? formatCents(
|
||||||
organization?.currency_format,
|
entry.cost,
|
||||||
organization?.currency_symbol,
|
props.currency,
|
||||||
organization?.number_format
|
organization?.currency_format,
|
||||||
)
|
organization?.currency_symbol,
|
||||||
: '--'
|
organization?.number_format
|
||||||
}}
|
)
|
||||||
|
: '--'
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ defineProps<{
|
|||||||
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
|
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
|
||||||
<dt class="font-medium text-sm text-text-secondary">{{ title }}</dt>
|
<dt class="font-medium text-sm text-text-secondary">{{ title }}</dt>
|
||||||
<dd class="text-xl text-text-primary pt-1 font-medium">
|
<dd class="text-xl text-text-primary pt-1 font-medium">
|
||||||
{{ value ?? '--' }}
|
<slot>{{ value ?? '--' }}</slot>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import TaskEditModal from '@/Components/Common/Task/TaskEditModal.vue';
|
|||||||
import { ref, inject, type ComputedRef } from 'vue';
|
import { ref, inject, type ComputedRef } from 'vue';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
||||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
|
||||||
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
||||||
import type { Organization } from '@/packages/api/src';
|
import type { Organization } from '@/packages/api/src';
|
||||||
|
|
||||||
@@ -55,7 +55,9 @@ const showTaskEditModal = ref(false);
|
|||||||
<span v-else> -- </span>
|
<span v-else> -- </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
||||||
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
<UpgradeBadge
|
||||||
|
v-if="!isAllowedToPerformPremiumAction()"
|
||||||
|
feature="estimates"></UpgradeBadge>
|
||||||
<EstimatedTimeProgress
|
<EstimatedTimeProgress
|
||||||
v-else-if="task.estimated_time"
|
v-else-if="task.estimated_time"
|
||||||
:estimated="task.estimated_time"
|
:estimated="task.estimated_time"
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
|
||||||
import { Link } from '@inertiajs/vue3';
|
|
||||||
import { isBillingActivated } from '@/utils/billing';
|
|
||||||
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
|
||||||
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
|
|
||||||
import { SecondaryButton } from '@/packages/ui/src';
|
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
|
||||||
<template #title>
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<span> Upgrade Plan </span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #content>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
class="rounded-full flex items-center justify-center w-20 h-20 mx-auto border border-border-tertiary bg-secondary">
|
|
||||||
<UserGroupIcon class="w-12"></UserGroupIcon>
|
|
||||||
</div>
|
|
||||||
<div class="max-w-sm text-center mx-auto py-4 text-base">
|
|
||||||
<p class="py-1">
|
|
||||||
<slot></slot>
|
|
||||||
</p>
|
|
||||||
<p class="py-1 text-sm">
|
|
||||||
If you want to use this feature,
|
|
||||||
<strong class="font-semibold text-text-primary"
|
|
||||||
>please upgrade to a paid plan</strong
|
|
||||||
>
|
|
||||||
or
|
|
||||||
<strong class="font-semibold text-text-primary"
|
|
||||||
>request a free trial</strong
|
|
||||||
>
|
|
||||||
via
|
|
||||||
<a
|
|
||||||
class="text-accent-200/80 transition text-accent-300"
|
|
||||||
href="mailto:hello@solidtime.io"
|
|
||||||
>hello@solidtime.io</a
|
|
||||||
>
|
|
||||||
to try out this feature.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
|
|
||||||
<PrimaryButton
|
|
||||||
v-if="isBillingActivated() && canUpdateOrganization()"
|
|
||||||
type="button"
|
|
||||||
class="mt-6"
|
|
||||||
:icon="CreditCardIcon">
|
|
||||||
Go to Billing
|
|
||||||
</PrimaryButton>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<SecondaryButton @click="show = false">Close</SecondaryButton>
|
|
||||||
</template>
|
|
||||||
</DialogModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import { use } from 'echarts/core';
|
import { use } from 'echarts/core';
|
||||||
import { CanvasRenderer } from 'echarts/renderers';
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
import { BarChart } from 'echarts/charts';
|
import { BarChart } from 'echarts/charts';
|
||||||
@@ -101,6 +103,8 @@ const { data: totalWeeklyBillableTime } = useQuery({
|
|||||||
staleTime: 1000 * 30, // 30 seconds
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
|
|
||||||
const { data: totalWeeklyBillableAmount } = useQuery({
|
const { data: totalWeeklyBillableAmount } = useQuery({
|
||||||
queryKey: ['totalWeeklyBillableAmount', organizationId],
|
queryKey: ['totalWeeklyBillableAmount', organizationId],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
@@ -110,7 +114,7 @@ const { data: totalWeeklyBillableAmount } = useQuery({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: computed(() => !!organizationId.value),
|
enabled: computed(() => !!organizationId.value && !billableRatesLocked.value),
|
||||||
staleTime: 1000 * 30, // 30 seconds
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,7 +274,11 @@ const option = computed(() => {
|
|||||||
)
|
)
|
||||||
: '--'
|
: '--'
|
||||||
" />
|
" />
|
||||||
|
<StatCard v-if="billableRatesLocked" title="Billable Amount">
|
||||||
|
<UpgradeLockedValue />
|
||||||
|
</StatCard>
|
||||||
<StatCard
|
<StatCard
|
||||||
|
v-else
|
||||||
title="Billable Amount"
|
title="Billable Amount"
|
||||||
:value="
|
:value="
|
||||||
totalWeeklyBillableAmount
|
totalWeeklyBillableAmount
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||||
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
||||||
import {
|
import {
|
||||||
@@ -126,6 +128,8 @@ const showBillableRate = computed(() => {
|
|||||||
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
|
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -190,17 +194,20 @@ const showBillableRate = computed(() => {
|
|||||||
<div
|
<div
|
||||||
v-if="showBillableRate"
|
v-if="showBillableRate"
|
||||||
class="justify-end pr-6 flex items-center font-medium">
|
class="justify-end pr-6 flex items-center font-medium">
|
||||||
{{
|
<UpgradeLockedValue v-if="billableRatesLocked" />
|
||||||
aggregatedTableTimeEntries.cost
|
<template v-else>
|
||||||
? formatCents(
|
{{
|
||||||
aggregatedTableTimeEntries.cost,
|
aggregatedTableTimeEntries.cost
|
||||||
getOrganizationCurrencyString(),
|
? formatCents(
|
||||||
organization?.currency_format,
|
aggregatedTableTimeEntries.cost,
|
||||||
organization?.currency_symbol,
|
getOrganizationCurrencyString(),
|
||||||
organization?.number_format
|
organization?.currency_format,
|
||||||
)
|
organization?.currency_symbol,
|
||||||
: '--'
|
organization?.number_format
|
||||||
}}
|
)
|
||||||
|
: '--'
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, usePage } from '@inertiajs/vue3';
|
import { Head, router, usePage } from '@inertiajs/vue3';
|
||||||
import Banner from '@/Components/Banner.vue';
|
import Banner from '@/Components/Banner.vue';
|
||||||
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
||||||
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
||||||
@@ -23,7 +23,7 @@ import { PanelLeft, Target } from '@lucide/vue';
|
|||||||
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
||||||
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import { nextTick, onMounted, provide, ref } from 'vue';
|
import { computed, nextTick, onMounted, provide, ref } from 'vue';
|
||||||
import NotificationContainer from '@/Components/NotificationContainer.vue';
|
import NotificationContainer from '@/Components/NotificationContainer.vue';
|
||||||
import { initializeStores } from '@/utils/init';
|
import { initializeStores } from '@/utils/init';
|
||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
@@ -38,12 +38,16 @@ import {
|
|||||||
canViewReport,
|
canViewReport,
|
||||||
canViewTags,
|
canViewTags,
|
||||||
} from '@/utils/permissions';
|
} from '@/utils/permissions';
|
||||||
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
import { canUseBillableRates, isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
||||||
|
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||||
|
import { billableRatesLockKey } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
|
import { upgradeCtaKey } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
|
||||||
import type { User } from '@/types/models';
|
import type { User } from '@/types/models';
|
||||||
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
||||||
import { fetchToken, isTokenValid } from '@/utils/session';
|
import { fetchToken, isTokenValid } from '@/utils/session';
|
||||||
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
||||||
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
||||||
|
import SidebarPlanStatus from '@/Components/Billing/SidebarPlanStatus.vue';
|
||||||
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
|
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
|
||||||
import { useTheme } from '@/utils/theme';
|
import { useTheme } from '@/utils/theme';
|
||||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||||
@@ -89,6 +93,19 @@ const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
|
|||||||
|
|
||||||
provide('organization', organization);
|
provide('organization', organization);
|
||||||
|
|
||||||
|
const showBillableRatesUpgradeModal = ref(false);
|
||||||
|
provide(billableRatesLockKey, {
|
||||||
|
locked: computed(() => !canUseBillableRates()),
|
||||||
|
requestUpgrade: () => {
|
||||||
|
showBillableRatesUpgradeModal.value = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
provide(upgradeCtaKey, {
|
||||||
|
canUpgrade: computed(() => isBillingActivated() && canManageBilling()),
|
||||||
|
goToBilling: () => router.visit('/billing'),
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
useTheme();
|
useTheme();
|
||||||
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
|
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
|
||||||
@@ -306,6 +323,7 @@ const page = usePage<{
|
|||||||
</div>
|
</div>
|
||||||
<div class="justify-self-end">
|
<div class="justify-self-end">
|
||||||
<UpdateSidebarNotification></UpdateSidebarNotification>
|
<UpdateSidebarNotification></UpdateSidebarNotification>
|
||||||
|
<SidebarPlanStatus v-if="isBillingActivated() && canManageBilling()" />
|
||||||
<ul
|
<ul
|
||||||
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
|
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
|
||||||
<UserSettingsIcon></UserSettingsIcon>
|
<UserSettingsIcon></UserSettingsIcon>
|
||||||
@@ -384,4 +402,5 @@ const page = usePage<{
|
|||||||
<NotificationContainer></NotificationContainer>
|
<NotificationContainer></NotificationContainer>
|
||||||
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
||||||
<CommandPaletteProvider></CommandPaletteProvider>
|
<CommandPaletteProvider></CommandPaletteProvider>
|
||||||
|
<UpgradeModal v-model:show="showBillableRatesUpgradeModal" feature="billable-rates" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,19 +2,15 @@
|
|||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
import { Target } from '@lucide/vue';
|
import { Target } from '@lucide/vue';
|
||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import GoalTable from '@/Components/Common/Goal/GoalTable.vue';
|
import GoalTable from '@/Components/Common/Goal/GoalTable.vue';
|
||||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.vue';
|
||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
|
||||||
import { canCreateGoals } from '@/utils/permissions';
|
import { canCreateGoals } from '@/utils/permissions';
|
||||||
import { canCreateMoreGoals, goalLimit } from '@/utils/billing';
|
import { canCreateMoreGoals } from '@/utils/billing';
|
||||||
import { useGoalsQuery } from '@/utils/useGoalsQuery';
|
import { useGoalsQuery } from '@/utils/useGoalsQuery';
|
||||||
import { TabBar, TabBarItem } from '@/packages/ui/src';
|
import { TabBar, TabBarItem } from '@/packages/ui/src';
|
||||||
|
|
||||||
const showCreateGoalModal = ref(false);
|
|
||||||
const activeTab = ref('active');
|
const activeTab = ref('active');
|
||||||
const showArchived = computed(() => activeTab.value === 'archived');
|
const showArchived = computed(() => activeTab.value === 'archived');
|
||||||
|
|
||||||
@@ -25,7 +21,6 @@ const visibleGoals = computed(() =>
|
|||||||
);
|
);
|
||||||
const archivedCount = computed(() => goals.value.filter((goal) => goal.is_archived).length);
|
const archivedCount = computed(() => goals.value.filter((goal) => goal.is_archived).length);
|
||||||
const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
|
const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
|
||||||
const limit = goalLimit();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -42,19 +37,9 @@ const limit = goalLimit();
|
|||||||
</TabBarItem>
|
</TabBarItem>
|
||||||
</TabBar>
|
</TabBar>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-3">
|
<GoalCreateButton
|
||||||
<UpgradeBadge v-if="canCreateGoals() && !canCreateMore">
|
v-if="canCreateGoals()"
|
||||||
<strong>More than {{ limit }} goal{{ limit === 1 ? '' : 's' }}</strong> is only
|
:can-create-more-goals="canCreateMore"></GoalCreateButton>
|
||||||
available in solidtime Professional.
|
|
||||||
</UpgradeBadge>
|
|
||||||
<SecondaryButton
|
|
||||||
v-if="canCreateGoals() && canCreateMore"
|
|
||||||
:icon="PlusIcon"
|
|
||||||
@click="showCreateGoalModal = true"
|
|
||||||
>Create Goal
|
|
||||||
</SecondaryButton>
|
|
||||||
</div>
|
|
||||||
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
|
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<GoalTable
|
<GoalTable
|
||||||
:goals="visibleGoals"
|
:goals="visibleGoals"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||||
|
import { canUseBillableRates } from '@/utils/billing';
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
||||||
@@ -80,6 +82,9 @@ const shownTasks = computed(() => {
|
|||||||
return task.project_id === projectId && task.is_done;
|
return task.project_id === projectId && task.is_done;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: The page sits above the app layout that provides the lock, so it can not inject it
|
||||||
|
const billableRatesLocked = computed(() => !canUseBillableRates());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -117,11 +122,17 @@ const shownTasks = computed(() => {
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<div class="px-4 space-x-1">
|
<div class="px-4 space-x-1">
|
||||||
<Badge v-if="project?.billable_rate">
|
<UpgradeLockedBadge v-if="billableRatesLocked"
|
||||||
|
>Billable Rates</UpgradeLockedBadge
|
||||||
|
>
|
||||||
|
<Badge v-else-if="project?.billable_rate">
|
||||||
{{ billableRateFormatted }}
|
{{ billableRateFormatted }}
|
||||||
/ h
|
/ h
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge v-if="project?.is_billable && !project?.billable_rate">
|
<Badge
|
||||||
|
v-if="
|
||||||
|
project?.is_billable && !project?.billable_rate && !billableRatesLocked
|
||||||
|
">
|
||||||
Default Rate
|
Default Rate
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>
|
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { useMembersQuery } from '@/utils/useMembersQuery';
|
|||||||
import { useQueryClient } from '@tanstack/vue-query';
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
||||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||||
import type { ExportFormat } from '@/types/reporting';
|
import type { ExportFormat } from '@/types/reporting';
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||||
@@ -253,9 +253,7 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
<ReportingExportModal
|
<ReportingExportModal
|
||||||
v-model:show="showExportModal"
|
v-model:show="showExportModal"
|
||||||
:export-url="exportUrl"></ReportingExportModal>
|
:export-url="exportUrl"></ReportingExportModal>
|
||||||
<UpgradeModal v-model:show="showPremiumModal">
|
<UpgradeModal v-model:show="showPremiumModal" feature="pdf-reports" />
|
||||||
<strong>PDF Reports</strong> are only available in solidtime Professional.
|
|
||||||
</UpgradeModal>
|
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
|
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
|
||||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { useNotificationsStore } from '@/utils/notification';
|
|||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { Field, FieldLabel } from '@/packages/ui/src/field';
|
import { Field, FieldLabel } from '@/packages/ui/src/field';
|
||||||
import { DocumentIcon } from '@heroicons/vue/24/solid';
|
import { DocumentIcon } from '@heroicons/vue/24/solid';
|
||||||
import { ArrowDownOnSquareIcon, InformationCircleIcon } from '@heroicons/vue/24/outline';
|
import {
|
||||||
|
ArrowDownOnSquareIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { canUseBillableRates } from '@/utils/billing';
|
||||||
|
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import type { ImportReport, ImportType } from '@/packages/api/src';
|
import type { ImportReport, ImportType } from '@/packages/api/src';
|
||||||
@@ -21,6 +26,8 @@ const { addNotification } = useNotificationsStore();
|
|||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const billableRatesLocked = computed(() => !canUseBillableRates());
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const organizationId = getCurrentOrganizationId();
|
const organizationId = getCurrentOrganizationId();
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
@@ -155,8 +162,19 @@ const showResultModal = ref(false);
|
|||||||
<div class="py-2 px-3 sm:px-4 text-sm flex items-center space-x-3">
|
<div class="py-2 px-3 sm:px-4 text-sm flex items-center space-x-3">
|
||||||
<InformationCircleIcon class="h-5 min-w-0 w-5 text-bg-tertiary" />
|
<InformationCircleIcon class="h-5 min-w-0 w-5 text-bg-tertiary" />
|
||||||
<p class="flex-1">
|
<p class="flex-1">
|
||||||
Import existing data from Toggl, Clockify or a different solidtime instance.
|
Import existing data from Toggl, Clockify, Harvest, generic CSV files or a
|
||||||
Please select the type of data you want to import and follow the instructions.
|
different solidtime instance. Please select the type of data you want to import
|
||||||
|
and follow the instructions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="billableRatesLocked"
|
||||||
|
class="py-2 px-3 sm:px-4 text-sm flex items-center space-x-3 border-t border-card-background-separator">
|
||||||
|
<ExclamationTriangleIcon class="h-5 min-w-0 w-5 text-yellow-500" />
|
||||||
|
<p class="flex-1">
|
||||||
|
Billable rates and billable status will not be imported, since they are not
|
||||||
|
available in the free plan. All projects and time entries will be imported as
|
||||||
|
non-billable.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import { storeToRefs } from 'pinia';
|
|||||||
import OrganizationBillableRateModal from '@/Components/Common/Organization/OrganizationBillableRateModal.vue';
|
import OrganizationBillableRateModal from '@/Components/Common/Organization/OrganizationBillableRateModal.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { Checkbox } from '@/packages/ui/src';
|
import { Checkbox } from '@/packages/ui/src';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
|
|
||||||
const store = useOrganizationStore();
|
const store = useOrganizationStore();
|
||||||
const { fetchOrganization, updateOrganization } = store;
|
const { fetchOrganization, updateOrganization } = store;
|
||||||
const { organization } = storeToRefs(store);
|
const { organization } = storeToRefs(store);
|
||||||
|
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const organizationBody = ref<UpdateOrganizationBody>({
|
const organizationBody = ref<UpdateOrganizationBody>({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -75,9 +77,8 @@ function checkForConfirmationModal() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
v-if="organization"
|
v-if="organization"
|
||||||
id="organizationShowBillableRatesToEmployees"
|
id="organizationShowBillableRatesToEmployees"
|
||||||
v-model:checked="
|
v-model:checked="organizationBody.employees_can_see_billable_rates"
|
||||||
organizationBody.employees_can_see_billable_rates
|
:disabled="billableRatesLocked"></Checkbox>
|
||||||
"></Checkbox>
|
|
||||||
<FieldLabel for="organizationShowBillableRatesToEmployees"
|
<FieldLabel for="organizationShowBillableRatesToEmployees"
|
||||||
>Show Billable Rates to Employees</FieldLabel
|
>Show Billable Rates to Employees</FieldLabel
|
||||||
>
|
>
|
||||||
@@ -85,7 +86,10 @@ function checkForConfirmationModal() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<PrimaryButton @click="checkForConfirmationModal">Save</PrimaryButton>
|
<PrimaryButton v-if="billableRatesLocked" @click="requestUpgrade"
|
||||||
|
>Upgrade</PrimaryButton
|
||||||
|
>
|
||||||
|
<PrimaryButton v-else @click="checkForConfirmationModal">Save</PrimaryButton>
|
||||||
</template>
|
</template>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
51
resources/js/Pages/Teams/Partials/OrganizationTrial.vue
Normal file
51
resources/js/Pages/Teams/Partials/OrganizationTrial.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, ref, type ComputedRef } from 'vue';
|
||||||
|
import { usePage } from '@inertiajs/vue3';
|
||||||
|
import ActionSection from '@/Components/ActionSection.vue';
|
||||||
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
|
import CancelTrialModal from '@/Components/Billing/CancelTrialModal.vue';
|
||||||
|
import { daysLeftInTrial } from '@/utils/billing';
|
||||||
|
import { formatDateLocalized } from '@/packages/ui/src/utils/time';
|
||||||
|
import type { Organization } from '@/packages/api/src';
|
||||||
|
|
||||||
|
const showCancelTrialModal = ref(false);
|
||||||
|
|
||||||
|
const organization = inject<ComputedRef<Organization | undefined>>('organization');
|
||||||
|
const page = usePage<{ billing: { trial_until: string | null } | null }>();
|
||||||
|
const trialUntil = computed(() =>
|
||||||
|
page.props.billing?.trial_until
|
||||||
|
? formatDateLocalized(page.props.billing.trial_until, organization?.value?.date_format)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const daysLeftLabel = computed(() => {
|
||||||
|
const days = daysLeftInTrial();
|
||||||
|
return days === 1 ? '1 day' : `${days} days`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ActionSection>
|
||||||
|
<template #title>Trial</template>
|
||||||
|
|
||||||
|
<template #description>Your organization is trying out solidtime Professional.</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="max-w-xl text-sm text-text-secondary">
|
||||||
|
Your trial ends in
|
||||||
|
<span class="font-medium text-text-primary">{{ daysLeftLabel }}</span>
|
||||||
|
<template v-if="trialUntil"> ({{ trialUntil }})</template>. If you don't need the
|
||||||
|
Professional features, you can switch to the Free plan right away.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<SecondaryButton
|
||||||
|
data-testid="cancel_trial_button"
|
||||||
|
@click="showCancelTrialModal = true">
|
||||||
|
Cancel trial
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CancelTrialModal v-model:show="showCancelTrialModal" />
|
||||||
|
</template>
|
||||||
|
</ActionSection>
|
||||||
|
</template>
|
||||||
@@ -8,6 +8,9 @@ import type { Permissions } from '@/types/jetstream';
|
|||||||
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
|
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
|
||||||
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
|
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
|
||||||
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
|
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
|
||||||
|
import OrganizationTrial from '@/Pages/Teams/Partials/OrganizationTrial.vue';
|
||||||
|
import { isBillingActivated, isInTrial } from '@/utils/billing';
|
||||||
|
import { canManageBilling } from '@/utils/permissions';
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
@@ -52,6 +55,11 @@ onMounted(async () => {
|
|||||||
<SectionBorder v-if="permissions.canUpdateTeam" />
|
<SectionBorder v-if="permissions.canUpdateTeam" />
|
||||||
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
|
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
|
||||||
|
|
||||||
|
<template v-if="isBillingActivated() && isInTrial() && canManageBilling()">
|
||||||
|
<SectionBorder />
|
||||||
|
<OrganizationTrial />
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-if="permissions.canDeleteTeam">
|
<template v-if="permissions.canDeleteTeam">
|
||||||
<SectionBorder />
|
<SectionBorder />
|
||||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ const IntervalFormat = z.enum([
|
|||||||
const TimeFormat = z.enum(['12-hours', '24-hours']);
|
const TimeFormat = z.enum(['12-hours', '24-hours']);
|
||||||
const OrganizationResource = z
|
const OrganizationResource = z
|
||||||
.object({
|
.object({
|
||||||
|
can_use_billable_rates: z.boolean().optional(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
is_personal: z.boolean(),
|
is_personal: z.boolean(),
|
||||||
@@ -5069,6 +5070,45 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/v1/organizations/:organization/trial/cancel',
|
||||||
|
alias: 'cancelTrial',
|
||||||
|
description: `The organization switches to the free plan right away.`,
|
||||||
|
requestFormat: 'json',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'organization',
|
||||||
|
type: 'Path',
|
||||||
|
schema: z.string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: z.void(),
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
description: `API exception`,
|
||||||
|
schema: z
|
||||||
|
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||||
|
.passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
description: `Unauthenticated`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
description: `Authorization error`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
description: `Not found`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/v1/public/reports',
|
path: '/v1/public/reports',
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
NumberFieldIncrement,
|
NumberFieldIncrement,
|
||||||
NumberFieldInput,
|
NumberFieldInput,
|
||||||
} from '..';
|
} from '..';
|
||||||
|
import UpgradeLockedField from './UpgradeLockedField.vue';
|
||||||
|
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -20,6 +22,8 @@ const model = defineModel<number | null>({
|
|||||||
default: null,
|
default: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { locked } = useBillableRatesLock();
|
||||||
|
|
||||||
const billableRateInput = ref<HTMLInputElement | null>(null);
|
const billableRateInput = ref<HTMLInputElement | null>(null);
|
||||||
useFocus(billableRateInput, { initialValue: props.focus });
|
useFocus(billableRateInput, { initialValue: props.focus });
|
||||||
|
|
||||||
@@ -30,7 +34,9 @@ function formatValue(modelValue: number | null) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
<UpgradeLockedField v-if="locked" />
|
||||||
<NumberField
|
<NumberField
|
||||||
|
v-else
|
||||||
:id="name"
|
:id="name"
|
||||||
ref="billableRateInput"
|
ref="billableRateInput"
|
||||||
:model-value="formatValue(model)"
|
:model-value="formatValue(model)"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -10,7 +11,13 @@ import {
|
|||||||
} from '@/packages/ui/src/tooltip';
|
} from '@/packages/ui/src/tooltip';
|
||||||
const active = defineModel<boolean>({ default: false });
|
const active = defineModel<boolean>({ default: false });
|
||||||
const emit = defineEmits(['changed']);
|
const emit = defineEmits(['changed']);
|
||||||
|
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
|
||||||
function toggleBillable() {
|
function toggleBillable() {
|
||||||
|
// Organizations with locked billable rates can remove the billable flag, but not set it
|
||||||
|
if (billableRatesLocked.value && !active.value) {
|
||||||
|
requestUpgrade();
|
||||||
|
return;
|
||||||
|
}
|
||||||
active.value = !active.value;
|
active.value = !active.value;
|
||||||
emit('changed', active.value);
|
emit('changed', active.value);
|
||||||
}
|
}
|
||||||
@@ -26,7 +33,12 @@ const props = withDefaults(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const tooltipLabel = computed(() => (active.value ? 'Billable' : 'Non Billable'));
|
const tooltipLabel = computed(() => {
|
||||||
|
if (billableRatesLocked.value && !active.value) {
|
||||||
|
return 'Billable rates require an upgrade';
|
||||||
|
}
|
||||||
|
return active.value ? 'Billable' : 'Non Billable';
|
||||||
|
});
|
||||||
|
|
||||||
const iconColorClasses = computed(() => {
|
const iconColorClasses = computed(() => {
|
||||||
if (active.value) {
|
if (active.value) {
|
||||||
|
|||||||
23
resources/js/packages/ui/src/Input/UpgradeLockedField.vue
Normal file
23
resources/js/packages/ui/src/Input/UpgradeLockedField.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
||||||
|
|
||||||
|
const { requestUpgrade } = useBillableRatesLock();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="billable_rates_locked"
|
||||||
|
class="flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input-border bg-input-background px-3 text-left text-sm text-text-tertiary shadow-sm transition hover:border-border-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
@click.prevent.stop="requestUpgrade">
|
||||||
|
<span class="flex min-w-0 items-center gap-2">
|
||||||
|
<LockClosedIcon class="w-3.5 shrink-0 text-icon-default" />
|
||||||
|
<span class="truncate"><slot>Billable rates</slot></span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded border border-border-secondary bg-secondary px-2 py-0.5 text-xs font-semibold text-text-secondary">
|
||||||
|
Upgrade
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -10,21 +10,27 @@ import {
|
|||||||
} from '@/packages/ui/src/tooltip';
|
} from '@/packages/ui/src/tooltip';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
currency: string;
|
currency: string;
|
||||||
organizationBillableRate: number | null;
|
organizationBillableRate: number | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
|
|
||||||
type RateType = 'default-rate' | 'custom-rate';
|
type RateType = 'default-rate' | 'custom-rate';
|
||||||
|
|
||||||
const billableDefault = ref<'billable' | 'non-billable'>('non-billable');
|
const billableDefault = ref<'billable' | 'non-billable'>('non-billable');
|
||||||
const rateType = ref<RateType>('default-rate');
|
const rateType = ref<RateType>('default-rate');
|
||||||
|
const originallyBillable = ref(false);
|
||||||
|
const billableFlagLocked = computed(() => billableRatesLocked.value && !originallyBillable.value);
|
||||||
|
|
||||||
const billableRate = defineModel<number | null>('billableRate');
|
const billableRate = defineModel<number | null>('billableRate');
|
||||||
const isBillable = defineModel<boolean>('isBillable');
|
const isBillable = defineModel<boolean>('isBillable');
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
originallyBillable.value = isBillable.value === true;
|
||||||
if (isBillable.value === true) {
|
if (isBillable.value === true) {
|
||||||
billableDefault.value = 'billable';
|
billableDefault.value = 'billable';
|
||||||
rateType.value = billableRate.value ? 'custom-rate' : 'default-rate';
|
rateType.value = billableRate.value ? 'custom-rate' : 'default-rate';
|
||||||
@@ -66,6 +72,9 @@ const displayedRate = computed({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const billableDescription = computed(() => {
|
const billableDescription = computed(() => {
|
||||||
|
if (billableFlagLocked.value) {
|
||||||
|
return 'Your organization’s plan does not allow enabling billable projects.';
|
||||||
|
}
|
||||||
if (billableDefault.value === 'non-billable') {
|
if (billableDefault.value === 'non-billable') {
|
||||||
return 'New time entries for this project will not be marked billable by default.';
|
return 'New time entries for this project will not be marked billable by default.';
|
||||||
}
|
}
|
||||||
@@ -84,14 +93,19 @@ const emit = defineEmits(['submit']);
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="non-billable">Non-billable</SelectItem>
|
<SelectItem value="non-billable">Non-billable</SelectItem>
|
||||||
<SelectItem value="billable">Billable</SelectItem>
|
<SelectItem value="billable" :disabled="billableFlagLocked">Billable</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FieldDescription>{{ billableDescription }}</FieldDescription>
|
<FieldDescription>{{ billableDescription }}</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
|
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<BillableRateInput
|
||||||
|
v-if="billableRatesLocked"
|
||||||
|
v-model="displayedRate"
|
||||||
|
:currency="currency"
|
||||||
|
name="billableRate" />
|
||||||
|
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
<Select v-model="rateType">
|
<Select v-model="rateType">
|
||||||
<SelectTrigger id="billableRateType">
|
<SelectTrigger id="billableRateType">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||||
import { Button } from '@/packages/ui/src/Buttons';
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
|
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
|
||||||
import type { Tag, Task } from '@/packages/api/src';
|
import type { Tag, Task } from '@/packages/api/src';
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -189,7 +191,9 @@ const billableProxy = computed({
|
|||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="true">Billable</SelectItem>
|
<SelectItem value="true" :disabled="billableRatesLocked"
|
||||||
|
>Billable</SelectItem
|
||||||
|
>
|
||||||
<SelectItem value="false">Non Billable</SelectItem>
|
<SelectItem value="false">Non Billable</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||||
import { Button } from '@/packages/ui/src/Buttons';
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||||
@@ -64,6 +65,7 @@ watch(show, (value) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const editableTimeEntry = ref<TimeEntry | null>(null);
|
const editableTimeEntry = ref<TimeEntry | null>(null);
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.timeEntry,
|
() => props.timeEntry,
|
||||||
@@ -134,6 +136,11 @@ async function deleteEntry() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Organizations with locked billable rates can not make a non-billable time entry billable
|
||||||
|
const billableOptionLocked = computed(
|
||||||
|
() => billableRatesLocked.value && !props.timeEntry?.billable
|
||||||
|
);
|
||||||
|
|
||||||
const billableProxy = computed({
|
const billableProxy = computed({
|
||||||
get: () =>
|
get: () =>
|
||||||
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
|
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
|
||||||
@@ -245,7 +252,9 @@ const typeProxy = computed({
|
|||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="true">Billable</SelectItem>
|
<SelectItem value="true" :disabled="billableOptionLocked"
|
||||||
|
>Billable</SelectItem
|
||||||
|
>
|
||||||
<SelectItem value="false">Non Billable</SelectItem>
|
<SelectItem value="false">Non Billable</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ import { XMarkIcon } from '@heroicons/vue/16/solid';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||||
import { Button } from '@/packages/ui/src/Buttons';
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import type { Tag, Task } from '@/packages/api/src';
|
import type { Tag, Task } from '@/packages/api/src';
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
|
// Organizations with locked billable rates can remove the billable flag, but not set it
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -263,7 +266,9 @@ const showBreakWarning = computed(
|
|||||||
<SelectItem value="do-not-update">
|
<SelectItem value="do-not-update">
|
||||||
Keep current billable status
|
Keep current billable status
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="billable">Billable</SelectItem>
|
<SelectItem value="billable" :disabled="billableRatesLocked"
|
||||||
|
>Billable</SelectItem
|
||||||
|
>
|
||||||
<SelectItem value="non-billable">Non Billable</SelectItem>
|
<SelectItem value="non-billable">Non Billable</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
||||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
import UpgradeModal from './UpgradeModal.vue';
|
||||||
|
import type { UpgradeFeature } from './UpgradeModal/useUpgradeCta';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
// The Professional feature behind the badge, picks the variant of the upgrade modal
|
||||||
|
feature: UpgradeFeature;
|
||||||
|
}>();
|
||||||
|
|
||||||
const showUpgradeModal = ref(false);
|
const showUpgradeModal = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UpgradeModal v-model:show="showUpgradeModal">
|
<UpgradeModal v-model:show="showUpgradeModal" :feature="feature" />
|
||||||
<slot>
|
|
||||||
<strong>Project and Task Estimates</strong> is only available in solidtime Professional.
|
|
||||||
</slot>
|
|
||||||
</UpgradeModal>
|
|
||||||
<button
|
<button
|
||||||
|
data-testid="upgrade_badge"
|
||||||
class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1"
|
class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1"
|
||||||
@click.prevent="showUpgradeModal = true">
|
@click.prevent="showUpgradeModal = true">
|
||||||
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>
|
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>
|
||||||
17
resources/js/packages/ui/src/UpgradeLockedBadge.vue
Normal file
17
resources/js/packages/ui/src/UpgradeLockedBadge.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import { useBillableRatesLock } from './utils/useBillableRatesLock';
|
||||||
|
|
||||||
|
const { requestUpgrade } = useBillableRatesLock();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="billable_rates_locked_badge"
|
||||||
|
class="inline-flex items-center space-x-1 rounded border border-border-secondary bg-secondary px-2 py-1 hover:border-border-tertiary hover:bg-tertiary"
|
||||||
|
@click.prevent.stop="requestUpgrade">
|
||||||
|
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>
|
||||||
|
<span class="text-xs font-semibold text-text-secondary"><slot>Upgrade</slot></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
22
resources/js/packages/ui/src/UpgradeLockedValue.vue
Normal file
22
resources/js/packages/ui/src/UpgradeLockedValue.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import { Button } from './Buttons';
|
||||||
|
import { useBillableRatesLock } from './utils/useBillableRatesLock';
|
||||||
|
|
||||||
|
const { requestUpgrade } = useBillableRatesLock();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
--
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-6 w-6 text-text-tertiary hover:text-text-primary"
|
||||||
|
aria-label="Upgrade to unlock billable rates"
|
||||||
|
data-testid="billable_rates_locked_button"
|
||||||
|
@click.prevent.stop="requestUpgrade">
|
||||||
|
<LockClosedIcon />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
139
resources/js/packages/ui/src/UpgradeModal.vue
Normal file
139
resources/js/packages/ui/src/UpgradeModal.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './dialog';
|
||||||
|
import { LockClosedIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import { computed, type Component } from 'vue';
|
||||||
|
import MockupBillableRates from './UpgradeModal/MockupBillableRates.vue';
|
||||||
|
import MockupEstimates from './UpgradeModal/MockupEstimates.vue';
|
||||||
|
import MockupGoals from './UpgradeModal/MockupGoals.vue';
|
||||||
|
import MockupInvoicing from './UpgradeModal/MockupInvoicing.vue';
|
||||||
|
import MockupPdfReports from './UpgradeModal/MockupPdfReports.vue';
|
||||||
|
import MockupShareableReports from './UpgradeModal/MockupShareableReports.vue';
|
||||||
|
import UpgradeCtaFooter from './UpgradeModal/UpgradeCtaFooter.vue';
|
||||||
|
import { featureIcons } from './UpgradeModal/featureIcons';
|
||||||
|
import {
|
||||||
|
professionalFeatures,
|
||||||
|
type ProfessionalFeatureKey,
|
||||||
|
type UpgradeFeature,
|
||||||
|
} from './UpgradeModal/useUpgradeCta';
|
||||||
|
|
||||||
|
const show = defineModel<boolean>('show', { default: false });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
// The Professional feature the user tried to use, picks the mock-up and the copy
|
||||||
|
feature: UpgradeFeature;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const contents: Record<
|
||||||
|
UpgradeFeature,
|
||||||
|
{
|
||||||
|
mockup: Component;
|
||||||
|
pill: string;
|
||||||
|
headline: string;
|
||||||
|
body: string;
|
||||||
|
// Feature of the pricing page that is listed first, if the feature is listed there
|
||||||
|
featureKey?: ProfessionalFeatureKey;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
'billable-rates': {
|
||||||
|
mockup: MockupBillableRates,
|
||||||
|
pill: 'Unlock billable rates',
|
||||||
|
headline: 'Turn tracked hours into revenue',
|
||||||
|
body: 'Upgrade to solidtime Professional to track billable hours',
|
||||||
|
featureKey: 'rates',
|
||||||
|
},
|
||||||
|
estimates: {
|
||||||
|
mockup: MockupEstimates,
|
||||||
|
pill: 'Unlock time estimates',
|
||||||
|
headline: 'Keep every project on budget',
|
||||||
|
body: 'Upgrade to solidtime Professional to set time estimates',
|
||||||
|
featureKey: 'estimates',
|
||||||
|
},
|
||||||
|
goals: {
|
||||||
|
mockup: MockupGoals,
|
||||||
|
pill: 'Unlock unlimited goals',
|
||||||
|
headline: 'Set a goal for everything that matters',
|
||||||
|
body: 'Upgrade to solidtime Professional to track unlimited goals',
|
||||||
|
},
|
||||||
|
invoicing: {
|
||||||
|
mockup: MockupInvoicing,
|
||||||
|
pill: 'Unlock invoicing',
|
||||||
|
headline: 'Get paid for every tracked hour',
|
||||||
|
body: 'Upgrade to solidtime Professional to create invoices',
|
||||||
|
featureKey: 'invoicing',
|
||||||
|
},
|
||||||
|
'pdf-reports': {
|
||||||
|
mockup: MockupPdfReports,
|
||||||
|
pill: 'Unlock PDF reports',
|
||||||
|
headline: 'Send reports your clients will read',
|
||||||
|
body: 'Upgrade to solidtime Professional to export PDF reports',
|
||||||
|
featureKey: 'pdf',
|
||||||
|
},
|
||||||
|
'shareable-reports': {
|
||||||
|
mockup: MockupShareableReports,
|
||||||
|
pill: 'Unlock shareable reports',
|
||||||
|
headline: 'Share live reports with one link',
|
||||||
|
body: 'Upgrade to solidtime Professional to share reports',
|
||||||
|
featureKey: 'shared',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = computed(() => contents[props.feature]);
|
||||||
|
|
||||||
|
// The feature the user tried to use comes first
|
||||||
|
const orderedFeatures = computed(() => [
|
||||||
|
...professionalFeatures.filter((feature) => feature.key === content.value.featureKey),
|
||||||
|
...professionalFeatures.filter((feature) => feature.key !== content.value.featureKey),
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="show">
|
||||||
|
<DialogContent class="sm:max-w-xl p-0 overflow-hidden">
|
||||||
|
<div class="relative">
|
||||||
|
<component :is="content.mockup" />
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-1/2 z-10 -translate-x-1/2 translate-y-1/2 whitespace-nowrap">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-3 py-1 dark:border-border-secondary dark:bg-default-background text-xs font-medium text-text-primary shadow-sm">
|
||||||
|
<LockClosedIcon class="h-3.5 w-3.5 text-accent-500 dark:text-accent-300" />
|
||||||
|
{{ content.pill }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-8 pt-9 text-center">
|
||||||
|
<DialogTitle class="text-xl font-semibold tracking-tight text-text-primary">
|
||||||
|
{{ content.headline }}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription class="mt-1.5 text-sm text-text-secondary">
|
||||||
|
{{ content.body }}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-x-6 gap-y-4 px-8 pt-9">
|
||||||
|
<div v-for="item in orderedFeatures" :key="item.key" class="flex gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border-tertiary bg-card-background">
|
||||||
|
<component
|
||||||
|
:is="featureIcons[item.key]"
|
||||||
|
class="h-3.5 w-3.5 text-text-secondary" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-text-primary">{{ item.title }}</p>
|
||||||
|
<p class="mt-0.5 text-xs leading-relaxed text-text-tertiary">
|
||||||
|
{{ item.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-8 pt-9 pb-6">
|
||||||
|
<UpgradeCtaFooter
|
||||||
|
align="stack"
|
||||||
|
label="Upgrade to Professional"
|
||||||
|
class="mx-auto max-w-xs"
|
||||||
|
@close="show = false" />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LockClosedIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import MockupFrame from './MockupFrame.vue';
|
||||||
|
|
||||||
|
const previewRows = [
|
||||||
|
{ name: 'Acme Corp', duration: '42h 30min', cost: '€ 5,100.00' },
|
||||||
|
{ name: 'Globex', duration: '27h 15min', cost: '€ 3,270.00' },
|
||||||
|
{ name: 'Initech', duration: '12h 45min', cost: '€ 1,530.00' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MockupFrame :glow-in-light-mode="false">
|
||||||
|
<div class="px-6 pt-4">
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[1fr_100px_100px] border-b border-gray-300/70 pb-1.5 text-[11px] text-gray-400 dark:border-white/10 dark:text-white/40">
|
||||||
|
<span>Client</span>
|
||||||
|
<span class="text-right">Duration</span>
|
||||||
|
<span class="flex items-center justify-end gap-1">
|
||||||
|
<LockClosedIcon class="h-3 w-3" />
|
||||||
|
Cost
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="row in previewRows"
|
||||||
|
:key="row.name"
|
||||||
|
class="grid grid-cols-[1fr_100px_100px] border-b border-gray-300/50 py-1.5 text-sm last:border-b-0 dark:border-white/5">
|
||||||
|
<span class="text-gray-800 dark:text-white/90">{{ row.name }}</span>
|
||||||
|
<span class="text-right tabular-nums text-gray-500 dark:text-white/50">{{
|
||||||
|
row.duration
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
class="select-none text-right font-medium text-accent-600 blur-[5px] dark:text-accent-300"
|
||||||
|
>{{ row.cost }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MockupFrame>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LockClosedIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import MockupFrame from './MockupFrame.vue';
|
||||||
|
|
||||||
|
const previewRows = [
|
||||||
|
{ name: 'Website Redesign', spent: '32h', estimate: '40h', progress: 80, muted: false },
|
||||||
|
{ name: 'Mobile App', spent: '11h', estimate: '20h', progress: 55, muted: true },
|
||||||
|
{ name: 'Support Retainer', spent: '6h', estimate: '20h', progress: 30, muted: false },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MockupFrame :glow-in-light-mode="false">
|
||||||
|
<div class="px-6 pt-4">
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[1fr_170px] border-b border-gray-300/70 pb-1.5 text-[11px] text-gray-400 dark:border-white/10 dark:text-white/40">
|
||||||
|
<span>Project</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<LockClosedIcon class="h-3 w-3" />
|
||||||
|
Progress
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="row in previewRows"
|
||||||
|
:key="row.name"
|
||||||
|
class="grid grid-cols-[1fr_170px] items-center border-b border-gray-300/50 py-1.5 text-sm last:border-b-0 dark:border-white/5">
|
||||||
|
<span class="text-gray-800 dark:text-white/90">{{ row.name }}</span>
|
||||||
|
<div class="flex select-none items-center gap-2.5 blur-[3px]">
|
||||||
|
<div
|
||||||
|
class="h-1.5 flex-1 overflow-hidden rounded-full bg-gray-300/60 dark:bg-white/10">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full"
|
||||||
|
:class="
|
||||||
|
row.muted
|
||||||
|
? 'bg-gray-400 dark:bg-white/40'
|
||||||
|
: 'bg-accent-500 dark:bg-accent-300'
|
||||||
|
"
|
||||||
|
:style="{ width: row.progress + '%' }" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="w-16 text-right text-xs tabular-nums text-gray-500 dark:text-white/60"
|
||||||
|
>{{ row.spent }} / {{ row.estimate }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MockupFrame>
|
||||||
|
</template>
|
||||||
23
resources/js/packages/ui/src/UpgradeModal/MockupFrame.vue
Normal file
23
resources/js/packages/ui/src/UpgradeModal/MockupFrame.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
// Show the blue glow in light mode too, dark mode always has it
|
||||||
|
glowInLightMode?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ glowInLightMode: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Note: Shared header of the upgrade modal, all mock-ups have the same height -->
|
||||||
|
<div
|
||||||
|
class="relative h-[156px] overflow-hidden border-b border-border-tertiary bg-background dark:border-white/10 dark:bg-[#0b1220]">
|
||||||
|
<!-- Note: Glow rises from behind the divider, around the pill -->
|
||||||
|
<div
|
||||||
|
:class="glowInLightMode ? '' : 'hidden dark:block'"
|
||||||
|
class="pointer-events-none absolute -bottom-32 left-1/2 h-56 w-[30rem] -translate-x-1/2 rounded-full bg-accent-400/25 blur-3xl dark:bg-accent-500/[0.18]" />
|
||||||
|
<div class="relative h-full" aria-hidden="true">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
54
resources/js/packages/ui/src/UpgradeModal/MockupGoals.vue
Normal file
54
resources/js/packages/ui/src/UpgradeModal/MockupGoals.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LockClosedIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import MockupFrame from './MockupFrame.vue';
|
||||||
|
|
||||||
|
// Note: The first goal is the one of the free plan, the others are locked
|
||||||
|
const previewGoals = [
|
||||||
|
{
|
||||||
|
name: 'Billable hours per month',
|
||||||
|
tracked: '62h',
|
||||||
|
target: '80h',
|
||||||
|
progress: 78,
|
||||||
|
locked: false,
|
||||||
|
},
|
||||||
|
{ name: 'Deep work per week', tracked: '14h', target: '20h', progress: 70, locked: true },
|
||||||
|
{ name: 'Support per week', tracked: '3h', target: '8h', progress: 40, locked: true },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MockupFrame :glow-in-light-mode="false">
|
||||||
|
<div class="px-6 pt-4">
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[1fr_170px] border-b border-gray-300/70 pb-1.5 text-[11px] text-gray-400 dark:border-white/10 dark:text-white/40">
|
||||||
|
<span>Goal</span>
|
||||||
|
<span>Progress</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="goal in previewGoals"
|
||||||
|
:key="goal.name"
|
||||||
|
class="grid grid-cols-[1fr_170px] items-center border-b border-gray-300/50 py-1.5 text-sm last:border-b-0 dark:border-white/5">
|
||||||
|
<span class="flex items-center gap-1.5 text-gray-800 dark:text-white/90">
|
||||||
|
<LockClosedIcon
|
||||||
|
v-if="goal.locked"
|
||||||
|
class="h-3 w-3 text-gray-400 dark:text-white/40" />
|
||||||
|
{{ goal.name }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="flex select-none items-center gap-2.5"
|
||||||
|
:class="goal.locked ? 'blur-[3px]' : ''">
|
||||||
|
<div
|
||||||
|
class="h-1.5 flex-1 overflow-hidden rounded-full bg-gray-300/60 dark:bg-white/10">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-accent-500 dark:bg-accent-300"
|
||||||
|
:style="{ width: goal.progress + '%' }" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="w-16 text-right text-xs tabular-nums text-gray-500 dark:text-white/60"
|
||||||
|
>{{ goal.tracked }} / {{ goal.target }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MockupFrame>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import MockupFrame from './MockupFrame.vue';
|
||||||
|
|
||||||
|
const lineItems = [
|
||||||
|
{ name: 'Website Redesign', hours: '32h', amount: '€ 3,840.00' },
|
||||||
|
{ name: 'Mobile App', hours: '18h', amount: '€ 2,160.00' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MockupFrame>
|
||||||
|
<!-- Note: An invoice created from tracked time, it runs off the bottom edge of the header -->
|
||||||
|
<div
|
||||||
|
class="absolute left-1/2 top-5 w-60 -translate-x-1/2 rounded-t-md border border-b-0 border-gray-200 bg-white px-5 pt-4 pb-10 shadow-xl shadow-gray-400/25 dark:border-transparent dark:shadow-2xl dark:shadow-black/50">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] font-semibold text-gray-900">Invoice 2026-014</p>
|
||||||
|
<p class="text-[9px] text-gray-500">Acme Corp · Due November 14</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="rounded bg-accent-500 px-1.5 py-0.5 text-[8px] font-bold tracking-wide text-white"
|
||||||
|
>SENT</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 space-y-1 text-[9px] text-gray-600">
|
||||||
|
<div v-for="item in lineItems" :key="item.name" class="flex justify-between">
|
||||||
|
<span>{{ item.name }} · {{ item.hours }}</span>
|
||||||
|
<span class="tabular-nums">{{ item.amount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-2 flex justify-between border-t border-gray-200 pt-1.5 text-[10px] font-semibold text-gray-900">
|
||||||
|
<span>Total</span>
|
||||||
|
<span class="tabular-nums">€ 6,000.00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MockupFrame>
|
||||||
|
</template>
|
||||||
35
resources/js/packages/ui/src/UpgradeModal/MockupPdfPage.vue
Normal file
35
resources/js/packages/ui/src/UpgradeModal/MockupPdfPage.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const bars = [45, 70, 55, 85, 65, 35, 50];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Note: A PDF page that runs off the bottom edge of the header -->
|
||||||
|
<div
|
||||||
|
class="rounded-t-md border border-b-0 border-gray-200 bg-white px-5 pt-4 pb-10 shadow-xl shadow-gray-400/25 dark:border-transparent dark:shadow-2xl dark:shadow-black/50">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] font-semibold text-gray-900">Time report</p>
|
||||||
|
<p class="text-[9px] text-gray-500">Acme Corp · October 2026</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="rounded bg-red-500 px-1.5 py-0.5 text-[8px] font-bold tracking-wide text-white"
|
||||||
|
>PDF</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2.5 flex h-7 items-end gap-1">
|
||||||
|
<div
|
||||||
|
v-for="(height, index) in bars"
|
||||||
|
:key="index"
|
||||||
|
class="flex-1 rounded-sm bg-accent-500/70"
|
||||||
|
:style="{ height: height + '%' }" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 space-y-1 text-[9px] text-gray-600">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Website Redesign</span><span class="tabular-nums">32h 15min</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Mobile App</span><span class="tabular-nums">18h 40min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import MockupFrame from './MockupFrame.vue';
|
||||||
|
import MockupPdfPage from './MockupPdfPage.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MockupFrame>
|
||||||
|
<!-- Note: The PDF report with two more pages fanned out behind it -->
|
||||||
|
<div class="absolute left-1/2 top-5 h-full w-56 -translate-x-1/2">
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 top-3 h-48 origin-bottom -translate-x-10 -rotate-[8deg] rounded-md border border-gray-200 bg-white/80 shadow-lg shadow-gray-400/20 dark:border-transparent dark:bg-white/70 dark:shadow-xl dark:shadow-black/40" />
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 top-3 h-48 origin-bottom translate-x-10 rotate-[8deg] rounded-md border border-gray-200 bg-white/80 shadow-lg shadow-gray-400/20 dark:border-transparent dark:bg-white/70 dark:shadow-xl dark:shadow-black/40" />
|
||||||
|
<MockupPdfPage class="absolute inset-x-0 top-0" />
|
||||||
|
</div>
|
||||||
|
</MockupFrame>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LinkIcon, LockClosedIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import MockupFrame from './MockupFrame.vue';
|
||||||
|
|
||||||
|
const previewRows = [
|
||||||
|
{ name: 'Website Redesign', duration: '32h 15min' },
|
||||||
|
{ name: 'Mobile App', duration: '18h 40min' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MockupFrame>
|
||||||
|
<!-- Note: A browser window showing the shared report, it runs into the bottom edge -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-10 top-4 -bottom-px rounded-t-lg border border-b-0 border-gray-300/80 bg-white shadow-xl shadow-gray-400/20 dark:border-white/10 dark:bg-white/[0.04] dark:shadow-2xl dark:shadow-black/40">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 border-b border-gray-200 px-3 py-2 dark:border-white/10">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-gray-300 dark:bg-white/15" />
|
||||||
|
<span class="h-2 w-2 rounded-full bg-gray-300 dark:bg-white/15" />
|
||||||
|
<span class="h-2 w-2 rounded-full bg-gray-300 dark:bg-white/15" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-1 items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-[11px] text-gray-500 dark:bg-black/30 dark:text-white/50">
|
||||||
|
<LockClosedIcon class="h-3 w-3 shrink-0 text-gray-400 dark:text-white/30" />
|
||||||
|
<span class="truncate">app.solidtime.io/shared-report</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="flex shrink-0 items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] font-medium text-gray-700 dark:border-transparent dark:bg-white/15 dark:text-white/90">
|
||||||
|
<LinkIcon class="h-3 w-3" />
|
||||||
|
Copy link
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 pt-2 pb-1.5">
|
||||||
|
<p class="py-1.5 text-xs font-medium text-gray-900 dark:text-white/90">
|
||||||
|
Acme Corp · October
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="row in previewRows"
|
||||||
|
:key="row.name"
|
||||||
|
class="flex justify-between border-b border-gray-100 py-1 text-xs last:border-b-0 dark:border-white/5">
|
||||||
|
<span class="text-gray-600 dark:text-white/70">{{ row.name }}</span>
|
||||||
|
<span class="tabular-nums text-gray-400 dark:text-white/50">{{
|
||||||
|
row.duration
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MockupFrame>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import { pricingUrl, useUpgradeCta } from './useUpgradeCta';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label?: string;
|
||||||
|
align?: 'between' | 'end' | 'stack';
|
||||||
|
showCompare?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ label: 'Upgrade to Professional', align: 'between', showCompare: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
const { canUpgrade, goToBilling } = useUpgradeCta();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
props.align === 'stack'
|
||||||
|
? 'flex flex-col items-center gap-3'
|
||||||
|
: 'flex flex-wrap items-center gap-2'
|
||||||
|
">
|
||||||
|
<a
|
||||||
|
v-if="props.align !== 'stack' && props.showCompare"
|
||||||
|
:href="pricingUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="mr-auto inline-flex items-center gap-1 text-xs text-text-tertiary transition-colors hover:text-text-primary">
|
||||||
|
Compare plans
|
||||||
|
<ArrowTopRightOnSquareIcon class="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
<template v-if="canUpgrade">
|
||||||
|
<button
|
||||||
|
v-if="props.align !== 'stack'"
|
||||||
|
type="button"
|
||||||
|
class="h-9 whitespace-nowrap rounded-md px-3 text-sm font-medium text-text-secondary transition-colors hover:bg-white/5 hover:text-text-primary"
|
||||||
|
@click="emit('close')">
|
||||||
|
Not now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="upgrade_modal_cta"
|
||||||
|
class="h-9 whitespace-nowrap rounded-md bg-accent-500 px-4 text-sm font-semibold text-white shadow-[0_0_24px_-6px] shadow-accent-500/60 transition-colors hover:bg-accent-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
:class="props.align === 'stack' ? 'w-full' : ''"
|
||||||
|
@click="goToBilling">
|
||||||
|
{{ props.label }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-xs text-text-tertiary">Only the organization owner can upgrade.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-9 rounded-md border border-border-secondary px-3 text-sm font-medium text-text-primary transition-colors hover:bg-white/5"
|
||||||
|
@click="emit('close')">
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<div v-if="props.align === 'stack'" class="flex items-center gap-4 text-xs">
|
||||||
|
<button
|
||||||
|
v-if="canUpgrade"
|
||||||
|
type="button"
|
||||||
|
class="text-text-tertiary transition-colors hover:text-text-primary"
|
||||||
|
@click="emit('close')">
|
||||||
|
Not now
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
:href="pricingUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-1 text-text-tertiary transition-colors hover:text-text-primary">
|
||||||
|
Compare plans
|
||||||
|
<ArrowTopRightOnSquareIcon class="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
19
resources/js/packages/ui/src/UpgradeModal/featureIcons.ts
Normal file
19
resources/js/packages/ui/src/UpgradeModal/featureIcons.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Component } from 'vue';
|
||||||
|
import {
|
||||||
|
AdjustmentsHorizontalIcon,
|
||||||
|
ClockIcon,
|
||||||
|
CurrencyEuroIcon,
|
||||||
|
DocumentArrowDownIcon,
|
||||||
|
DocumentTextIcon,
|
||||||
|
LinkIcon,
|
||||||
|
} from '@heroicons/vue/16/solid';
|
||||||
|
import type { ProfessionalFeatureKey } from './useUpgradeCta';
|
||||||
|
|
||||||
|
export const featureIcons: Record<ProfessionalFeatureKey, Component> = {
|
||||||
|
rates: CurrencyEuroIcon,
|
||||||
|
invoicing: DocumentTextIcon,
|
||||||
|
estimates: ClockIcon,
|
||||||
|
rounding: AdjustmentsHorizontalIcon,
|
||||||
|
pdf: DocumentArrowDownIcon,
|
||||||
|
shared: LinkIcon,
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user