mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 05:43:18 +01:00
Compare commits
4 Commits
project-ta
...
feature/ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf7bc076cf | ||
|
|
f6b6b0a467 | ||
|
|
95645ddd91 | ||
|
|
1de985b577 |
2
.github/workflows/build-private.yml
vendored
2
.github/workflows/build-private.yml
vendored
@@ -97,7 +97,7 @@ jobs:
|
||||
- name: "Install dependencies in billing extension"
|
||||
uses: php-actions/composer@v6
|
||||
env:
|
||||
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "gregor@vostrak.at", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "${{ secrets.LARAVEL_SPARK_USERNAME }}", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||
with:
|
||||
working_dir: "extensions/Billing"
|
||||
command: install
|
||||
|
||||
136
.github/workflows/phpunit-extensions.yml
vendored
Normal file
136
.github/workflows/phpunit-extensions.yml
vendored
Normal file
@@ -0,0 +1,136 @@
|
||||
name: PHPUnit Tests - Extensions
|
||||
on: push
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
phpunit-extensions:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
matrix:
|
||||
postgres_version: [ 15, 16, 17 ]
|
||||
|
||||
services:
|
||||
pgsql_test:
|
||||
image: postgres:${{ matrix.postgres_version }}
|
||||
env:
|
||||
PGPASSWORD: 'root'
|
||||
POSTGRES_DB: 'laravel'
|
||||
POSTGRES_USER: 'root'
|
||||
POSTGRES_PASSWORD: 'root'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:8
|
||||
ports:
|
||||
- 3000:3000
|
||||
options: >-
|
||||
--health-cmd "curl --silent --fail http://localhost:3000/health"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Setup PHP"
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||
coverage: pcov
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: "Read extension manifest"
|
||||
id: extension-manifest
|
||||
run: |
|
||||
{
|
||||
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
|
||||
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
|
||||
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
|
||||
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
|
||||
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
||||
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Checkout billing extension"
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
|
||||
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
|
||||
path: extensions/Billing
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
|
||||
|
||||
- name: "Install composer dependencies in billing extension"
|
||||
working-directory: extensions/Billing
|
||||
env:
|
||||
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||
|
||||
- name: "Install npm dependencies in billing extension"
|
||||
run: cd extensions/Billing && npm ci
|
||||
|
||||
- name: "Checkout services extension"
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.extension-manifest.outputs.services_repository }}
|
||||
ref: ${{ steps.extension-manifest.outputs.services_ref }}
|
||||
path: extensions/Services
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
|
||||
|
||||
- name: "Install composer dependencies in services extension"
|
||||
working-directory: extensions/Services
|
||||
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||
|
||||
- name: "Install npm dependencies in services extension"
|
||||
run: cd extensions/Services && npm ci
|
||||
|
||||
- name: "Checkout invoicing extension"
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
||||
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
|
||||
path: extensions/Invoicing
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||
|
||||
- name: "Install composer dependencies in invoicing extension"
|
||||
working-directory: extensions/Invoicing
|
||||
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||
|
||||
- name: "Install npm dependencies in invoicing extension"
|
||||
run: cd extensions/Invoicing && npm ci
|
||||
|
||||
- name: "Run composer install"
|
||||
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||
|
||||
- name: "Activate billing extension"
|
||||
run: php artisan module:enable Billing
|
||||
|
||||
- name: "Activate services extension"
|
||||
run: php artisan module:enable Services
|
||||
|
||||
- name: "Activate invoicing extension"
|
||||
run: php artisan module:enable Invoicing
|
||||
|
||||
- name: "Install dependencies"
|
||||
run: npm ci
|
||||
|
||||
- name: "Build Frontend"
|
||||
run: npm run build
|
||||
|
||||
- name: "Prepare Laravel Application"
|
||||
run: |
|
||||
cp .env.ci .env
|
||||
php artisan key:generate
|
||||
php artisan passport:keys
|
||||
|
||||
- name: "Run PHPUnit"
|
||||
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure
|
||||
@@ -1,7 +1,6 @@
|
||||
ARG PHP_VERSION=8.3
|
||||
ARG FRANKENPHP_VERSION=1.8
|
||||
ARG FRANKENPHP_VERSION=1.11
|
||||
ARG COMPOSER_VERSION=2.8
|
||||
ARG BUN_VERSION="latest"
|
||||
ARG APP_ENV
|
||||
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
||||
|
||||
@@ -16,13 +15,13 @@ RUN CGO_ENABLED=1 \
|
||||
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
||||
CGO_CFLAGS=$(php-config --includes) \
|
||||
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
||||
xcaddy build v2.10.0 \
|
||||
xcaddy build \
|
||||
--output /usr/local/bin/frankenphp \
|
||||
--with github.com/dunglas/frankenphp=./ \
|
||||
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
||||
--with github.com/dunglas/caddy-cbrotli
|
||||
|
||||
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS base
|
||||
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
|
||||
|
||||
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
|
||||
|
||||
@@ -32,26 +31,28 @@ LABEL org.opencontainers.image.description="solidtime is a modern open source ti
|
||||
LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime"
|
||||
LABEL org.opencontainers.image.licenses="AGPL"
|
||||
|
||||
ARG WWWUSER=1000
|
||||
ARG WWWGROUP=1000
|
||||
ARG USER_ID=1000
|
||||
ARG GROUP_ID=1000
|
||||
ARG TZ=UTC
|
||||
ARG APP_DIR=/var/www/html
|
||||
ARG APP_ENV
|
||||
ARG APP_HOST
|
||||
ARG DOCKER_FILES_BASE_PATH
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
TERM=xterm-color \
|
||||
OCTANE_SERVER=frankenphp \
|
||||
TZ=${TZ} \
|
||||
USER=octane \
|
||||
ROOT=${APP_DIR} \
|
||||
APP_ENV=${APP_ENV} \
|
||||
LANG=C.UTF-8 \
|
||||
USER=laravel \
|
||||
ROOT=/var/www/html \
|
||||
APP_ENV=production \
|
||||
COMPOSER_ALLOW_SUPERUSER=1 \
|
||||
COMPOSER_FUND=0 \
|
||||
COMPOSER_MAX_PARALLEL_HTTP=24 \
|
||||
XDG_CONFIG_HOME=${APP_DIR}/.config \
|
||||
XDG_DATA_HOME=${APP_DIR}/.data \
|
||||
SERVER_NAME=${APP_HOST}
|
||||
COMPOSER_MAX_PARALLEL_HTTP=48 \
|
||||
WITH_HORIZON=false \
|
||||
WITH_SCHEDULER=false \
|
||||
WITH_REVERB=false \
|
||||
WITH_SSR=false
|
||||
|
||||
ENV XDG_CONFIG_HOME=${ROOT}/.config XDG_DATA_HOME=${ROOT}/.data
|
||||
|
||||
WORKDIR ${ROOT}
|
||||
|
||||
@@ -60,6 +61,9 @@ SHELL ["/bin/bash", "-eou", "pipefail", "-c"]
|
||||
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
|
||||
&& echo ${TZ} > /etc/timezone
|
||||
|
||||
RUN echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \
|
||||
echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom
|
||||
|
||||
RUN apt-get update; \
|
||||
apt-get upgrade -yqq; \
|
||||
apt-get install -yqq --no-install-recommends --show-progress \
|
||||
@@ -68,40 +72,36 @@ RUN apt-get update; \
|
||||
wget \
|
||||
vim \
|
||||
git \
|
||||
unzip \
|
||||
ncdu \
|
||||
procps \
|
||||
unzip \
|
||||
ca-certificates \
|
||||
supervisor \
|
||||
libsodium-dev \
|
||||
libbrotli-dev \
|
||||
# Install PHP extensions (included with dunglas/frankenphp)
|
||||
# && curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr bash \
|
||||
&& install-php-extensions \
|
||||
apcu \
|
||||
bz2 \
|
||||
pcntl \
|
||||
mbstring \
|
||||
bcmath \
|
||||
sockets \
|
||||
pgsql \
|
||||
pdo_pgsql \
|
||||
opcache \
|
||||
exif \
|
||||
pdo_mysql \
|
||||
zip \
|
||||
uv \
|
||||
vips \
|
||||
intl \
|
||||
gd \
|
||||
redis \
|
||||
rdkafka \
|
||||
memcached \
|
||||
igbinary \
|
||||
ffi \
|
||||
ldap \
|
||||
&& apt-get -y autoremove \
|
||||
&& apt-get clean \
|
||||
&& docker-php-source delete \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
||||
&& rm /var/log/lastlog /var/log/faillog
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/lastlog /var/log/faillog
|
||||
|
||||
RUN arch="$(uname -m)" \
|
||||
&& case "$arch" in \
|
||||
@@ -111,106 +111,64 @@ RUN arch="$(uname -m)" \
|
||||
x86) _cronic_fname='supercronic-linux-386' ;; \
|
||||
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
|
||||
esac \
|
||||
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/${_cronic_fname}" \
|
||||
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.38/${_cronic_fname}" \
|
||||
-O /usr/bin/supercronic \
|
||||
&& chmod +x /usr/bin/supercronic \
|
||||
&& mkdir -p /etc/supercronic \
|
||||
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
|
||||
|
||||
RUN userdel --remove --force www-data \
|
||||
&& groupadd --force -g ${WWWGROUP} ${USER} \
|
||||
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \
|
||||
&& setcap -r /usr/local/bin/frankenphp
|
||||
|
||||
RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \
|
||||
&& chmod -R a+rw ${ROOT} /var/{log,run}
|
||||
&& groupadd --force -g ${GROUP_ID} ${USER} \
|
||||
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${GROUP_ID} -u ${USER_ID} ${USER}
|
||||
|
||||
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
|
||||
|
||||
USER ${USER}
|
||||
COPY --link --from=vendor /usr/bin/composer /usr/bin/composer
|
||||
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
|
||||
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
|
||||
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
|
||||
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
|
||||
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
|
||||
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-php.ini
|
||||
#COPY --link composer.* ./
|
||||
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} --from=vendor /usr/bin/composer /usr/bin/composer
|
||||
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
|
||||
|
||||
RUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
|
||||
|
||||
###########################################
|
||||
|
||||
#FROM base AS common
|
||||
#
|
||||
#USER ${USER}
|
||||
#
|
||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
||||
#
|
||||
#RUN composer install \
|
||||
# --no-dev \
|
||||
# --no-interaction \
|
||||
# --no-autoloader \
|
||||
# --no-ansi \
|
||||
# --no-scripts \
|
||||
# --no-progress \
|
||||
# --audit
|
||||
|
||||
###########################################
|
||||
# Build frontend assets with Bun
|
||||
###########################################
|
||||
|
||||
#FROM oven/bun:${BUN_VERSION} AS build
|
||||
#
|
||||
#ARG APP_ENV
|
||||
#
|
||||
#ENV ROOT=/var/www/html \
|
||||
# APP_ENV=${APP_ENV} \
|
||||
# NODE_ENV=${APP_ENV:-production}
|
||||
#
|
||||
#WORKDIR ${ROOT}
|
||||
#
|
||||
#COPY --link package.json bun.lock* ./
|
||||
#
|
||||
|
||||
#RUN bun install --frozen-lockfile
|
||||
#
|
||||
#COPY --link . .
|
||||
#COPY --link --from=common ${ROOT}/vendor vendor
|
||||
#
|
||||
#RUN bun run build
|
||||
|
||||
###########################################
|
||||
|
||||
#FROM common AS runner
|
||||
|
||||
USER ${USER}
|
||||
|
||||
ENV WITH_HORIZON=false \
|
||||
WITH_SCHEDULER=false \
|
||||
WITH_REVERB=false
|
||||
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./
|
||||
|
||||
RUN test -z "$(find . -name .git -print -quit)"
|
||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
||||
COPY --link . .
|
||||
|
||||
RUN mkdir -p \
|
||||
storage/framework/{sessions,views,cache,testing} \
|
||||
storage/logs \
|
||||
bootstrap/cache && chmod -R a+rw storage
|
||||
bootstrap/cache \
|
||||
&& chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
|
||||
|
||||
#RUN composer install \
|
||||
# --classmap-authoritative \
|
||||
# --no-interaction \
|
||||
# --no-ansi \
|
||||
# --no-dev \
|
||||
# && composer clear-cache
|
||||
RUN composer dump-autoload \
|
||||
--optimize \
|
||||
--apcu \
|
||||
--no-dev
|
||||
|
||||
RUN cat .env
|
||||
#RUN php artisan env
|
||||
#RUN bun run build
|
||||
|
||||
RUN chown -R ${USER_ID}:${GROUP_ID} ${ROOT} \
|
||||
&& find / -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true
|
||||
|
||||
USER ${USER}
|
||||
|
||||
EXPOSE 8000
|
||||
#EXPOSE 2019
|
||||
#EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["start-container"]
|
||||
|
||||
#HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1
|
||||
#HEALTHCHECK --start-period=30s --interval=10s --timeout=3s --retries=3 CMD healthcheck || exit 1
|
||||
|
||||
@@ -22,6 +22,13 @@ elif [ "${container_mode}" = "reverb" ]; then
|
||||
echo "Healthcheck failed."
|
||||
exit 1
|
||||
fi
|
||||
elif [ "${container_mode}" = "ssr" ]; then
|
||||
if [ "$(supervisorctl status inertia-ssr-server:inertia-ssr-server_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||
exit 0
|
||||
else
|
||||
echo "Healthcheck failed."
|
||||
exit 1
|
||||
fi
|
||||
elif [ "${container_mode}" = "worker" ]; then
|
||||
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||
exit 0
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
|
||||
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
|
||||
|
||||
log {
|
||||
level {$CADDY_SERVER_LOG_LEVEL:WARN}
|
||||
}
|
||||
|
||||
auto_https off
|
||||
|
||||
skip_install_trust
|
||||
|
||||
frankenphp {
|
||||
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
|
||||
}
|
||||
@@ -20,7 +28,7 @@
|
||||
|
||||
{$CADDY_SERVER_SERVER_NAME} {
|
||||
log {
|
||||
level WARN
|
||||
level {$CADDY_SERVER_LOG_LEVEL:WARN}
|
||||
|
||||
format filter {
|
||||
wrap {$CADDY_SERVER_LOGGER}
|
||||
@@ -60,7 +68,6 @@
|
||||
error @rejected 401
|
||||
|
||||
php_server {
|
||||
index frankenphp-worker.php
|
||||
try_files {path} frankenphp-worker.php
|
||||
resolve_root_symlink
|
||||
}
|
||||
|
||||
@@ -1,65 +1,18 @@
|
||||
[program:octane]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
|
||||
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-host=0.0.0.0 --admin-port=2019 --log-level=WARN --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
|
||||
user = %(ENV_USER)s
|
||||
priority = 1
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stopwaitsecs = 30
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
environment = LARAVEL_OCTANE = "1"
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
stderr_logfile_maxbytes = 0
|
||||
|
||||
[program:horizon]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan horizon
|
||||
user = %(ENV_USER)s
|
||||
priority = 3
|
||||
autostart = %(ENV_WITH_HORIZON)s
|
||||
autorestart = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
stopwaitsecs = 3600
|
||||
|
||||
[program:scheduler]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = supercronic -overlapping /etc/supercronic/laravel
|
||||
user = %(ENV_USER)s
|
||||
autostart = %(ENV_WITH_SCHEDULER)s
|
||||
autorestart = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
|
||||
[program:clear-scheduler-cache]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user = %(ENV_USER)s
|
||||
autostart = %(ENV_WITH_SCHEDULER)s
|
||||
autorestart = false
|
||||
startsecs = 0
|
||||
startretries = 1
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
|
||||
[program:reverb]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan reverb:start
|
||||
user = %(ENV_USER)s
|
||||
priority = 2
|
||||
autostart = %(ENV_WITH_REVERB)s
|
||||
autorestart = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
minfds = 10000
|
||||
|
||||
[include]
|
||||
files = /etc/supervisord.conf
|
||||
files = /etc/supervisord.conf /etc/supervisor/conf.d/supervisord.services.conf
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
post_max_size = 100M
|
||||
upload_max_filesize = 100M
|
||||
expose_php = 0
|
||||
realpath_cache_size = 16M
|
||||
realpath_cache_ttl = 360
|
||||
realpath_cache_size = 32M
|
||||
realpath_cache_ttl = 720
|
||||
memory_limit = 256M
|
||||
max_input_time = 5
|
||||
register_argc_argv = 0
|
||||
date.timezone = ${TZ:-UTC}
|
||||
@@ -11,18 +12,24 @@ date.timezone = ${TZ:-UTC}
|
||||
[Opcache]
|
||||
opcache.enable = 1
|
||||
opcache.enable_cli = 1
|
||||
opcache.memory_consumption = 256M
|
||||
opcache.memory_consumption = 256
|
||||
opcache.use_cwd = 0
|
||||
opcache.save_comments = 1
|
||||
opcache.max_file_size = 0
|
||||
opcache.max_accelerated_files = 32531
|
||||
opcache.validate_timestamps = 0
|
||||
opcache.file_update_protection = 0
|
||||
opcache.interned_strings_buffer = 16
|
||||
opcache.enable_file_override = 1
|
||||
opcache.file_cache_consistency_checks = 0
|
||||
opcache.file_cache = /tmp/opcache-file-cache
|
||||
|
||||
[JIT]
|
||||
opcache.jit_buffer_size = 128M
|
||||
opcache.jit = function
|
||||
opcache.jit_prof_threshold = 0.001
|
||||
opcache.jit = tracing
|
||||
opcache.jit_hot_loop = 16
|
||||
opcache.jit_hot_func = 32
|
||||
opcache.jit_hot_return = 4
|
||||
opcache.jit_max_root_traces = 2048
|
||||
opcache.jit_max_side_traces = 256
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
[supervisord]
|
||||
nodaemon = true
|
||||
user = %(ENV_USER)s
|
||||
logfile = /var/log/supervisor/supervisord.log
|
||||
pidfile = /var/run/supervisord.pid
|
||||
|
||||
[supervisorctl]
|
||||
|
||||
@@ -10,4 +8,4 @@ pidfile = /var/run/supervisord.pid
|
||||
port = 127.0.0.1:9001
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
@@ -4,6 +4,8 @@ command = php %(ENV_ROOT)s/artisan horizon
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
|
||||
15
docker/prod/deployment/supervisord.inertia.conf
Normal file
15
docker/prod/deployment/supervisord.inertia.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
[program:inertia-ssr-server]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
stderr_logfile_maxbytes = 0
|
||||
|
||||
[include]
|
||||
files = /etc/supervisord.conf
|
||||
@@ -4,6 +4,8 @@ command = php %(ENV_ROOT)s/artisan reverb:start
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
|
||||
@@ -4,6 +4,8 @@ command = supercronic -overlapping /etc/supercronic/laravel
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
|
||||
69
docker/prod/deployment/supervisord.services.conf
Normal file
69
docker/prod/deployment/supervisord.services.conf
Normal file
@@ -0,0 +1,69 @@
|
||||
[program:horizon]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan horizon
|
||||
user = %(ENV_USER)s
|
||||
priority = 4
|
||||
autostart = %(ENV_WITH_HORIZON)s
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
stopwaitsecs = 3600
|
||||
|
||||
[program:scheduler]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = supercronic -overlapping /etc/supercronic/laravel
|
||||
user = %(ENV_USER)s
|
||||
autostart = %(ENV_WITH_SCHEDULER)s
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
|
||||
[program:clear-scheduler-cache]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user = %(ENV_USER)s
|
||||
autostart = %(ENV_WITH_SCHEDULER)s
|
||||
autorestart = false
|
||||
startsecs = 0
|
||||
startretries = 1
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
|
||||
[program:reverb]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan reverb:start
|
||||
user = %(ENV_USER)s
|
||||
priority = 3
|
||||
autostart = %(ENV_WITH_REVERB)s
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
minfds = 10000
|
||||
|
||||
[program:inertia-ssr-server]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
|
||||
user = %(ENV_USER)s
|
||||
priority = 2
|
||||
autostart = %(ENV_WITH_SSR)s
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
@@ -4,6 +4,8 @@ command = %(ENV_WORKER_COMMAND)s
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
|
||||
@@ -53,47 +53,6 @@ function todayAt(hour: number, minute: number = 0): string {
|
||||
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
// Cross-day tests anchor to Wednesday and Thursday of the current week instead of today and
|
||||
// tomorrow. E2E users start the week on Monday, so both days are always in the visible week,
|
||||
// whichever day the suite runs on.
|
||||
function wednesdayAt(hour: number, minute: number = 0): Date {
|
||||
const now = new Date();
|
||||
const daysSinceMonday = (now.getDay() + 6) % 7;
|
||||
return new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() - daysSinceMonday + 2,
|
||||
hour,
|
||||
minute,
|
||||
0,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function thursdayAt(hour: number, minute: number = 0): Date {
|
||||
const d = wednesdayAt(hour, minute);
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d;
|
||||
}
|
||||
|
||||
function toApiTimestamp(date: Date): string {
|
||||
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
// Local calendar date (YYYY-MM-DD), matching FullCalendar's data-date attributes
|
||||
function toDateStr(date: Date): string {
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
async function columnHeaderCenterX(page: Page, date: Date): Promise<number> {
|
||||
const header = page.locator(`.fc-col-header-cell[data-date="${toDateStr(date)}"]`);
|
||||
await expect(header).toBeVisible();
|
||||
const box = await header.boundingBox();
|
||||
return box!.x + box!.width / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* These tests verify that changing the project on a time entry via the calendar
|
||||
* updates the billable status to match the new project's is_billable setting.
|
||||
@@ -1003,9 +962,14 @@ test.describe('Drag-to-Move Events', () => {
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Create entry: Thursday 00:30 → Thursday 01:30 (1 hour, near midnight)
|
||||
const start = toApiTimestamp(thursdayAt(0, 30));
|
||||
const end = toApiTimestamp(thursdayAt(1, 30));
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
// Need today to have a previous day visible in the week view (skip Sunday with Monday week start)
|
||||
test.skip(dayOfWeek === 1, 'Skipping on Monday — previous day not visible in week view');
|
||||
|
||||
// Create entry: today 00:30 → today 01:30 (1 hour, near midnight)
|
||||
const start = todayAt(0, 30);
|
||||
const end = todayAt(1, 30);
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
description: 'Drag up past midnight test',
|
||||
start,
|
||||
@@ -1014,10 +978,11 @@ test.describe('Drag-to-Move Events', () => {
|
||||
await goToCalendar(page);
|
||||
await scrollCalendarToTime(page, '00:00:00');
|
||||
|
||||
const thursdayCol = page.locator(
|
||||
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
||||
);
|
||||
const event = thursdayCol
|
||||
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
|
||||
const event = todayCol
|
||||
.locator('.fc-event')
|
||||
.filter({ hasText: 'Drag up past midnight test' });
|
||||
await expect(event).toBeVisible({ timeout: 10000 });
|
||||
@@ -1053,7 +1018,10 @@ test.describe('Drag-to-Move Events', () => {
|
||||
expect(newDurationMs).toBe(3600000);
|
||||
|
||||
// The event should have moved to the previous day
|
||||
expect(toDateStr(newStart)).toBe(toDateStr(wednesdayAt(0)));
|
||||
const yesterdayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
expect(newStart.toISOString().split('T')[0]).toBe(yesterdayStr);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1293,8 +1261,8 @@ test.describe('Resize Events', () => {
|
||||
});
|
||||
|
||||
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
|
||||
const start = toApiTimestamp(wednesdayAt(10));
|
||||
const end = toApiTimestamp(wednesdayAt(11));
|
||||
const start = todayAt(10);
|
||||
const end = todayAt(11);
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
description: 'Cross resize end test',
|
||||
start,
|
||||
@@ -1303,8 +1271,26 @@ test.describe('Resize Events', () => {
|
||||
await goToCalendar(page);
|
||||
await scrollCalendarToTime(page, '09:00:00');
|
||||
|
||||
// Resize into Thursday's column (for end resize, end must be > start)
|
||||
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
|
||||
// Find a column AFTER today (for end resize, end must be > start)
|
||||
const headers = page.locator('.fc-col-header-cell');
|
||||
const headerCount = await headers.count();
|
||||
let targetX: number | undefined;
|
||||
let todayIndex = -1;
|
||||
for (let i = 0; i < headerCount; i++) {
|
||||
const header = headers.nth(i);
|
||||
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||||
if (isToday) {
|
||||
todayIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Pick first column after today, or skip if today is last
|
||||
for (let i = todayIndex + 1; i < headerCount; i++) {
|
||||
const box = await headers.nth(i).boundingBox();
|
||||
targetX = box!.x + box!.width / 2;
|
||||
break;
|
||||
}
|
||||
test.skip(targetX === undefined, 'No column after today to resize to');
|
||||
|
||||
const event = page
|
||||
.locator('.fc-event')
|
||||
@@ -1332,7 +1318,7 @@ test.describe('Resize Events', () => {
|
||||
// First drag down vertically to engage resize (like test 3.1)
|
||||
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
|
||||
// Then move horizontally to a later day column
|
||||
await page.mouse.move(targetX, bottomY + slotHeight * 4, { steps: 10 });
|
||||
await page.mouse.move(targetX!, bottomY + slotHeight * 4, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
})(),
|
||||
]);
|
||||
@@ -1354,8 +1340,8 @@ test.describe('Resize Events', () => {
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const start = toApiTimestamp(wednesdayAt(10));
|
||||
const end = toApiTimestamp(wednesdayAt(14));
|
||||
const start = todayAt(10);
|
||||
const end = todayAt(14);
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
description: 'Cross resize end test',
|
||||
start,
|
||||
@@ -1364,8 +1350,30 @@ test.describe('Resize Events', () => {
|
||||
await goToCalendar(page);
|
||||
await scrollCalendarToTime(page, '09:00:00');
|
||||
|
||||
// Resize into Thursday's column (later day needed for end-edge resize)
|
||||
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
|
||||
// Find a non-today column header that is AFTER today (later day needed for end-edge resize)
|
||||
const headers = page.locator('.fc-col-header-cell');
|
||||
const headerCount = await headers.count();
|
||||
let targetX: number | undefined;
|
||||
let foundToday = false;
|
||||
for (let i = 0; i < headerCount; i++) {
|
||||
const header = headers.nth(i);
|
||||
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||||
if (isToday) {
|
||||
foundToday = true;
|
||||
continue;
|
||||
}
|
||||
if (foundToday) {
|
||||
const box = await header.boundingBox();
|
||||
targetX = box!.x + box!.width / 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If today is the last column, use the one before today instead won't work for end resize,
|
||||
// so skip this test in that edge case
|
||||
if (targetX === undefined) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const event = page
|
||||
.locator('.fc-event')
|
||||
@@ -1390,7 +1398,7 @@ test.describe('Resize Events', () => {
|
||||
await page.waitForTimeout(100);
|
||||
await page.mouse.down();
|
||||
// Move to a different day column at same Y position
|
||||
await page.mouse.move(targetX, bottomY - 3, { steps: 15 });
|
||||
await page.mouse.move(targetX!, bottomY - 3, { steps: 15 });
|
||||
await page.mouse.up();
|
||||
})(),
|
||||
]);
|
||||
@@ -1456,9 +1464,22 @@ test.describe('Resize Events', () => {
|
||||
});
|
||||
|
||||
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
|
||||
// Create entry spanning Wednesday evening → Thursday morning
|
||||
const start = toApiTimestamp(wednesdayAt(20));
|
||||
const end = toApiTimestamp(thursdayAt(10));
|
||||
// Create entry spanning today evening → tomorrow morning
|
||||
const start = todayAt(20);
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
|
||||
const end = new Date(
|
||||
tomorrow.getFullYear(),
|
||||
tomorrow.getMonth(),
|
||||
tomorrow.getDate(),
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
.toISOString()
|
||||
.replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
description: 'Multi-day end resize',
|
||||
@@ -1467,14 +1488,14 @@ test.describe('Resize Events', () => {
|
||||
});
|
||||
await goToCalendar(page);
|
||||
|
||||
const thursdayCol = page.locator(
|
||||
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
||||
);
|
||||
// Check if tomorrow column is visible
|
||||
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
|
||||
|
||||
await scrollCalendarToTime(page, '09:00:00');
|
||||
|
||||
// Find the event segment on Thursday's column
|
||||
const event = thursdayCol
|
||||
// Find the event segment on tomorrow's column
|
||||
const event = tomorrowCol
|
||||
.locator('.fc-event')
|
||||
.filter({ hasText: 'Multi-day end resize' })
|
||||
.first();
|
||||
@@ -1520,9 +1541,22 @@ test.describe('Resize Events', () => {
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Create entry spanning Wednesday → Thursday
|
||||
const start = toApiTimestamp(wednesdayAt(10));
|
||||
const end = toApiTimestamp(thursdayAt(14));
|
||||
// Create entry spanning today → tomorrow
|
||||
const start = todayAt(10);
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
|
||||
const end = new Date(
|
||||
tomorrow.getFullYear(),
|
||||
tomorrow.getMonth(),
|
||||
tomorrow.getDate(),
|
||||
14,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
.toISOString()
|
||||
.replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
description: 'Backward end resize multi',
|
||||
@@ -1531,16 +1565,29 @@ test.describe('Resize Events', () => {
|
||||
});
|
||||
await goToCalendar(page);
|
||||
|
||||
const thursdayCol = page.locator(
|
||||
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
||||
);
|
||||
// Check if tomorrow column is visible
|
||||
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
|
||||
|
||||
await scrollCalendarToTime(page, '12:00:00');
|
||||
|
||||
const wednesdayX = await columnHeaderCenterX(page, wednesdayAt(0));
|
||||
// Find today's column header to get its X center
|
||||
const headers = page.locator('.fc-col-header-cell');
|
||||
const headerCount = await headers.count();
|
||||
let todayX: number | undefined;
|
||||
for (let i = 0; i < headerCount; i++) {
|
||||
const header = headers.nth(i);
|
||||
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||||
if (isToday) {
|
||||
const box = await header.boundingBox();
|
||||
todayX = box!.x + box!.width / 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
test.skip(todayX === undefined, 'Could not find today column header');
|
||||
|
||||
// Find event segment on Thursday's column and resize end backward to Wednesday
|
||||
const event = thursdayCol
|
||||
// Find event segment on tomorrow's column and resize end backward to today
|
||||
const event = tomorrowCol
|
||||
.locator('.fc-event')
|
||||
.filter({ hasText: 'Backward end resize multi' })
|
||||
.first();
|
||||
@@ -1562,9 +1609,9 @@ test.describe('Resize Events', () => {
|
||||
await page.mouse.move(centerX, bottomY - 3);
|
||||
await page.waitForTimeout(100);
|
||||
await page.mouse.down();
|
||||
// Drag down a bit first, then move to Wednesday's column at a Y after the start
|
||||
// Drag down a bit first, then move to today's column at a Y after the start
|
||||
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
|
||||
await page.mouse.move(wednesdayX, bottomY + slotHeight, { steps: 10 });
|
||||
await page.mouse.move(todayX!, bottomY + slotHeight, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
})(),
|
||||
]);
|
||||
@@ -1580,8 +1627,8 @@ test.describe('Resize Events', () => {
|
||||
});
|
||||
|
||||
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
|
||||
const start = toApiTimestamp(thursdayAt(10));
|
||||
const end = toApiTimestamp(thursdayAt(14));
|
||||
const start = todayAt(10);
|
||||
const end = todayAt(14);
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
description: 'End before start test',
|
||||
start,
|
||||
@@ -1590,8 +1637,25 @@ test.describe('Resize Events', () => {
|
||||
await goToCalendar(page);
|
||||
await scrollCalendarToTime(page, '09:00:00');
|
||||
|
||||
// Target Wednesday's column, the day before the entry
|
||||
const targetX = await columnHeaderCenterX(page, wednesdayAt(0));
|
||||
// Find a column BEFORE today
|
||||
const headers = page.locator('.fc-col-header-cell');
|
||||
const headerCount = await headers.count();
|
||||
let targetX: number | undefined;
|
||||
let todayIndex = -1;
|
||||
for (let i = 0; i < headerCount; i++) {
|
||||
const header = headers.nth(i);
|
||||
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||||
if (isToday) {
|
||||
todayIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = todayIndex - 1; i >= 0; i--) {
|
||||
const box = await headers.nth(i).boundingBox();
|
||||
targetX = box!.x + box!.width / 2;
|
||||
break;
|
||||
}
|
||||
test.skip(targetX === undefined, 'No column before today to test');
|
||||
|
||||
const event = page
|
||||
.locator('.fc-event')
|
||||
@@ -1619,7 +1683,7 @@ test.describe('Resize Events', () => {
|
||||
await page.waitForTimeout(100);
|
||||
await page.mouse.down();
|
||||
// Move to earlier column at a Y position near the top of the grid (before start time)
|
||||
await page.mouse.move(targetX, eventBox!.y - slotHeight * 4, { steps: 15 });
|
||||
await page.mouse.move(targetX!, eventBox!.y - slotHeight * 4, { steps: 15 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Wait for any potential API call
|
||||
@@ -1668,21 +1732,30 @@ test.describe('Click-Drag Selection to Create', () => {
|
||||
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
|
||||
page,
|
||||
}) => {
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
// Need today and tomorrow both visible (skip Saturday with Monday week start)
|
||||
test.skip(dayOfWeek === 6, 'Skipping on Saturday — tomorrow not visible in week view');
|
||||
|
||||
await goToCalendar(page);
|
||||
await expect(page.locator('.fc')).toBeVisible();
|
||||
// Use mid-day times so both start and end slots are visible in the viewport
|
||||
await scrollCalendarToTime(page, '10:00:00');
|
||||
|
||||
// Find Wednesday's and Thursday's columns
|
||||
const wednesdayStr = toDateStr(wednesdayAt(0));
|
||||
const thursdayStr = toDateStr(thursdayAt(0));
|
||||
// Find today's and tomorrow's columns
|
||||
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
const wednesdayCol = page.locator(`.fc-timegrid-col[data-date="${wednesdayStr}"]`);
|
||||
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${thursdayStr}"]`);
|
||||
await expect(wednesdayCol).toBeVisible();
|
||||
await expect(thursdayCol).toBeVisible();
|
||||
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
|
||||
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||
await expect(todayCol).toBeVisible();
|
||||
await expect(tomorrowCol).toBeVisible();
|
||||
|
||||
// Find the 11:00 slot (start) and 13:00 slot (end on Thursday)
|
||||
// Find the 11:00 slot (start) and 13:00 slot (end on tomorrow)
|
||||
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
|
||||
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first();
|
||||
await expect(startSlot).toBeVisible();
|
||||
@@ -1690,18 +1763,18 @@ test.describe('Click-Drag Selection to Create', () => {
|
||||
|
||||
const startSlotBox = await startSlot.boundingBox();
|
||||
const endSlotBox = await endSlot.boundingBox();
|
||||
const wednesdayColBox = await wednesdayCol.boundingBox();
|
||||
const thursdayColBox = await thursdayCol.boundingBox();
|
||||
const todayColBox = await todayCol.boundingBox();
|
||||
const tomorrowColBox = await tomorrowCol.boundingBox();
|
||||
|
||||
// Start drag at 11:00 on Wednesday's column
|
||||
const startX = wednesdayColBox!.x + wednesdayColBox!.width / 2;
|
||||
// Start drag at 11:00 on today's column
|
||||
const startX = todayColBox!.x + todayColBox!.width / 2;
|
||||
const startY = startSlotBox!.y + 2;
|
||||
|
||||
// End drag at 13:00 on Thursday's column
|
||||
const endX = thursdayColBox!.x + thursdayColBox!.width / 2;
|
||||
// End drag at 13:00 on tomorrow's column
|
||||
const endX = tomorrowColBox!.x + tomorrowColBox!.width / 2;
|
||||
const endY = endSlotBox!.y + 2;
|
||||
|
||||
// Drag from Wednesday to Thursday, moving down first, then across
|
||||
// Drag from today to tomorrow — move down first, then across
|
||||
const slotHeight = await getSlotHeight(page);
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
@@ -1719,9 +1792,9 @@ test.describe('Click-Drag Selection to Create', () => {
|
||||
await expect(dialog.getByText('Start')).toBeVisible();
|
||||
await expect(dialog.getByText('End')).toBeVisible();
|
||||
|
||||
// Start date should be Wednesday, end date should be Thursday
|
||||
await expect(dialog.getByText(wednesdayStr)).toBeVisible();
|
||||
await expect(dialog.getByText(thursdayStr)).toBeVisible();
|
||||
// Start date should be today, end date should be tomorrow
|
||||
await expect(dialog.getByText(todayStr)).toBeVisible();
|
||||
await expect(dialog.getByText(tomorrowStr)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1803,9 +1876,17 @@ test.describe('Timezone & Localization', () => {
|
||||
|
||||
test.describe('Multi-Day Events', () => {
|
||||
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
|
||||
// Create entry that spans from Wednesday 22:00 to Thursday 02:00
|
||||
const start = toApiTimestamp(wednesdayAt(22));
|
||||
const end = toApiTimestamp(thursdayAt(2));
|
||||
// Create entry that spans from today 22:00 to tomorrow 02:00
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
// If today is Saturday (6), the entry would span to next week and may not be visible
|
||||
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
|
||||
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||
|
||||
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
description: 'Multi day entry',
|
||||
@@ -1820,8 +1901,14 @@ test.describe('Multi-Day Events', () => {
|
||||
});
|
||||
|
||||
test('multi-day event can be edited via click', async ({ page, ctx }) => {
|
||||
const start = toApiTimestamp(wednesdayAt(22));
|
||||
const end = toApiTimestamp(thursdayAt(2));
|
||||
const now = new Date();
|
||||
test.skip(now.getDay() === 6, 'Skip on Saturday');
|
||||
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||
|
||||
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
description: 'Multi day edit test',
|
||||
@@ -1840,8 +1927,14 @@ test.describe('Multi-Day Events', () => {
|
||||
});
|
||||
|
||||
test('multi-day event context menu works', async ({ page, ctx }) => {
|
||||
const start = toApiTimestamp(wednesdayAt(22));
|
||||
const end = toApiTimestamp(thursdayAt(2));
|
||||
const now = new Date();
|
||||
test.skip(now.getDay() === 6, 'Skip on Saturday');
|
||||
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||
|
||||
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
description: 'Multi day ctx test',
|
||||
@@ -1861,11 +1954,16 @@ test.describe('Multi-Day Events', () => {
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
|
||||
const startDate = wednesdayAt(22);
|
||||
const endDate = thursdayAt(2);
|
||||
const start = toApiTimestamp(startDate);
|
||||
const end = toApiTimestamp(endDate);
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
// Need today and tomorrow both visible (skip Saturday)
|
||||
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
|
||||
|
||||
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
description: 'Multi day drag test',
|
||||
@@ -1875,9 +1973,12 @@ test.describe('Multi-Day Events', () => {
|
||||
await goToCalendar(page);
|
||||
await scrollCalendarToTime(page, '00:00:00');
|
||||
|
||||
// Find the clipped segment on Thursday's column (00:00-02:00)
|
||||
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
|
||||
const event = thursdayCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
|
||||
// Find the clipped segment on tomorrow's column (00:00-02:00)
|
||||
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||
const event = tomorrowCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
|
||||
await expect(event).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const eventBox = await event.boundingBox();
|
||||
@@ -1912,19 +2013,27 @@ test.describe('Multi-Day Events', () => {
|
||||
// Duration must be preserved (4 hours)
|
||||
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
|
||||
|
||||
// The start should still be on Wednesday (not jumped to Thursday)
|
||||
expect(toDateStr(newStart)).toBe(toDateStr(startDate));
|
||||
// The start should still be on today (not jumped to tomorrow)
|
||||
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
expect(newStart.toISOString().split('T')[0]).toBe(todayStr);
|
||||
});
|
||||
|
||||
test('dragging clipped segment of multi-day event upward shifts event earlier', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
|
||||
const startDate = wednesdayAt(22);
|
||||
const endDate = thursdayAt(2);
|
||||
const start = toApiTimestamp(startDate);
|
||||
const end = toApiTimestamp(endDate);
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
// Need today and tomorrow both visible (skip Saturday)
|
||||
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
|
||||
|
||||
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
description: 'Multi day drag up test',
|
||||
@@ -1934,9 +2043,12 @@ test.describe('Multi-Day Events', () => {
|
||||
await goToCalendar(page);
|
||||
await scrollCalendarToTime(page, '00:00:00');
|
||||
|
||||
// Find the clipped segment on Thursday's column (00:00-02:00)
|
||||
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
|
||||
const event = thursdayCol
|
||||
// Find the clipped segment on tomorrow's column (00:00-02:00)
|
||||
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||
const event = tomorrowCol
|
||||
.locator('.fc-event')
|
||||
.filter({ hasText: 'Multi day drag up test' });
|
||||
await expect(event).toBeVisible({ timeout: 10000 });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Billing": {
|
||||
"repository": "solidtime-io/extension-billing",
|
||||
"ref": "v0.0.6"
|
||||
"ref": "main"
|
||||
},
|
||||
"Services": {
|
||||
"repository": "solidtime-io/extension-services",
|
||||
|
||||
@@ -43,5 +43,7 @@
|
||||
<env name="NEWSLETTER_URL" value="null"/>
|
||||
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
|
||||
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
|
||||
<env name="PADDLE_API_KEY" value="test_phpunit_paddle_api_key"/>
|
||||
<env name="PADDLE_SANDBOX" value="true"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
@@ -10,21 +10,13 @@ import Pagination from '@/packages/ui/src/Pagination.vue';
|
||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||
|
||||
export type SortColumn =
|
||||
| 'name'
|
||||
| 'tasks'
|
||||
| 'client_name'
|
||||
| 'spent_time'
|
||||
| 'progress'
|
||||
| 'billable_rate'
|
||||
| 'status'
|
||||
| 'visibility';
|
||||
'name' | 'client_name' | 'spent_time' | 'progress' | 'billable_rate' | 'status' | 'visibility';
|
||||
export type { SortDirection } from '@/utils/useSortableTable';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||
import { useTasksQuery } from '@/utils/useTasksQuery';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||
@@ -64,16 +56,6 @@ const clientNameMap = computed(() => {
|
||||
return map;
|
||||
});
|
||||
|
||||
const { tasks } = useTasksQuery();
|
||||
|
||||
const taskCountMap = computed(() => {
|
||||
const map = new Map<string, number>();
|
||||
tasks.value.forEach((task) => {
|
||||
map.set(task.project_id, (map.get(task.project_id) ?? 0) + 1);
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
// Define column accessors for sorting.
|
||||
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
|
||||
// while text columns default to ascending (A-Z) on first click (chevron down).
|
||||
@@ -82,11 +64,6 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
|
||||
id: 'name',
|
||||
accessorFn: (row: Project) => row.name.toLowerCase(),
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
sortDescFirst: true,
|
||||
accessorFn: (row: Project) => taskCountMap.value.get(row.id) ?? 0,
|
||||
},
|
||||
{
|
||||
id: 'client_name',
|
||||
accessorFn: (row: Project) => {
|
||||
@@ -184,7 +161,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
|
||||
}
|
||||
|
||||
const gridTemplate = computed(() => {
|
||||
return `grid-template-columns: minmax(300px, 1fr) minmax(100px, auto) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
|
||||
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -36,9 +36,6 @@ function handleSort(column: SortColumn) {
|
||||
@sort="handleSort">
|
||||
Name
|
||||
</SortableTableHeaderCell>
|
||||
<SortableTableHeaderCell column="tasks" v-bind="sortState" @sort="handleSort">
|
||||
Tasks
|
||||
</SortableTableHeaderCell>
|
||||
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
|
||||
Client
|
||||
</SortableTableHeaderCell>
|
||||
|
||||
@@ -88,19 +88,17 @@ const showEditProjectModal = ref(false);
|
||||
<ContextMenuTrigger as-child>
|
||||
<TableRow :href="route('projects.show', { project: project.id })">
|
||||
<div
|
||||
class="whitespace-nowrap min-w-0 flex items-center space-x-3 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
|
||||
class="whitespace-nowrap min-w-0 flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
|
||||
<div
|
||||
:style="{ backgroundColor: project.color }"
|
||||
class="w-2.5 h-2.5 ml-1 rounded-full"></div>
|
||||
:style="{
|
||||
backgroundColor: project.color,
|
||||
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
|
||||
}"
|
||||
class="w-3 h-3 ml-1 rounded-full"></div>
|
||||
<span class="overflow-ellipsis overflow-hidden">
|
||||
{{ project.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||
<span v-if="projectTasksCount">
|
||||
{{ projectTasksCount }} {{ projectTasksCount === 1 ? 'Task' : 'Tasks' }}
|
||||
</span>
|
||||
<span v-else class="text-text-tertiary">--</span>
|
||||
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
|
||||
</div>
|
||||
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
|
||||
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
||||
|
||||
Reference in New Issue
Block a user