Initial commit — Aziros v1.0.0
commit
db8a012f73
|
|
@ -0,0 +1,6 @@
|
|||
DB_CONNECTION=mysql
|
||||
DB_HOST=db
|
||||
DB_DATABASE=nexxo
|
||||
DB_USERNAME=nexxo
|
||||
DB_PASSWORD=5d79bcb6f4ce1955ef835af6
|
||||
DB_ROOT_PASSWORD=49f12736549babe3a2638078b95b0407
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
src/.env
|
||||
src/.env.local
|
||||
src/.env.development
|
||||
src/.env.staging
|
||||
src/.env.production
|
||||
src/vendor/
|
||||
src/node_modules/
|
||||
src/public/build/
|
||||
src/storage/logs/
|
||||
src/storage/framework/cache/
|
||||
src/storage/framework/sessions/
|
||||
src/storage/framework/views/
|
||||
src/bootstrap/cache/
|
||||
db_data/
|
||||
*.log
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Aziros deploying..."
|
||||
|
||||
cd ~/aziros
|
||||
|
||||
# Migrations ausführen
|
||||
docker compose exec app php artisan migrate --force
|
||||
|
||||
# Cache leeren
|
||||
docker compose exec app php artisan config:clear
|
||||
docker compose exec app php artisan cache:clear
|
||||
docker compose exec app php artisan view:clear
|
||||
docker compose exec app php artisan route:clear
|
||||
|
||||
# Caches neu aufbauen
|
||||
docker compose exec app php artisan config:cache
|
||||
docker compose exec app php artisan route:cache
|
||||
docker compose exec app php artisan view:cache
|
||||
|
||||
echo "✅ Deploy fertig!"
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
services:
|
||||
db:
|
||||
networks:
|
||||
- nexxo
|
||||
image: mariadb:11
|
||||
container_name: nexxo_db
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
MARIADB_DATABASE: ${DB_DATABASE}
|
||||
MARIADB_USER: ${DB_USERNAME}
|
||||
MARIADB_PASSWORD: ${DB_PASSWORD}
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- ./db_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u root -p$$MARIADB_ROOT_PASSWORD || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 20s
|
||||
|
||||
app:
|
||||
networks:
|
||||
- nexxo
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/php/Dockerfile
|
||||
args:
|
||||
UID: 1000
|
||||
GID: 1000
|
||||
image: nexxo-php
|
||||
container_name: nexxo_app
|
||||
ports:
|
||||
- "5173:5173"
|
||||
restart: unless-stopped
|
||||
working_dir: /var/www/html
|
||||
volumes:
|
||||
- ./src:/var/www/html
|
||||
env_file: src/.env.development
|
||||
environment:
|
||||
DB_HOST: db
|
||||
REDIS_HOST: redis
|
||||
XDG_CONFIG_HOME: /tmp
|
||||
PSYSH_CONFIG_DIR: /tmp/psysh
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
networks:
|
||||
- nexxo
|
||||
image: nginx:alpine
|
||||
container_name: nexxo_web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./src:/var/www/html:ro
|
||||
- ./docker/nginx/local.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
redis:
|
||||
networks:
|
||||
- nexxo
|
||||
image: redis:7-alpine
|
||||
container_name: nexxo_redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
|
||||
mail-worker:
|
||||
networks:
|
||||
- nexxo
|
||||
image: nexxo-php
|
||||
container_name: nexxo_mail_worker
|
||||
restart: unless-stopped
|
||||
env_file: src/.env.development
|
||||
volumes:
|
||||
- ./src:/var/www/html
|
||||
working_dir: /var/www/html
|
||||
command: php artisan app:process-mail-queue
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
worker:
|
||||
networks:
|
||||
- nexxo
|
||||
image: nexxo-php
|
||||
container_name: nexxo_worker
|
||||
restart: unless-stopped
|
||||
env_file: src/.env.development
|
||||
volumes:
|
||||
- ./src:/var/www/html
|
||||
working_dir: /var/www/html
|
||||
command: php artisan queue:work --sleep=1 --tries=3 --timeout=120
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
scheduler:
|
||||
image: nexxo-php
|
||||
container_name: nexxo_scheduler
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nexxo
|
||||
volumes:
|
||||
- ./src:/var/www/html
|
||||
env_file: src/.env.development
|
||||
environment:
|
||||
DB_HOST: db
|
||||
REDIS_HOST: redis
|
||||
command: >
|
||||
sh -c '
|
||||
until nc -z db 3306; do sleep 1; done;
|
||||
php artisan schedule:work
|
||||
'
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
reverb:
|
||||
networks:
|
||||
- nexxo
|
||||
image: nexxo-php
|
||||
container_name: nexxo_reverb
|
||||
command: php artisan reverb:start --host=0.0.0.0 --port=8080
|
||||
env_file: src/.env.development
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./src:/var/www/html
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
ntfy:
|
||||
image: binwiederhier/ntfy
|
||||
container_name: nexxo_ntfy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8082:80"
|
||||
volumes:
|
||||
- ./docker/ntfy/cache:/var/cache/ntfy
|
||||
- ./docker/ntfy/config:/etc/ntfy
|
||||
command:
|
||||
- serve
|
||||
|
||||
|
||||
horizon:
|
||||
networks:
|
||||
- nexxo
|
||||
image: nexxo-php
|
||||
container_name: nexxo_horizon
|
||||
command: php artisan horizon
|
||||
volumes:
|
||||
- ./src:/var/www/html
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
networks:
|
||||
nexxo:
|
||||
driver: bridge
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
services:
|
||||
db:
|
||||
networks:
|
||||
- nexxo
|
||||
image: mariadb:11
|
||||
container_name: nexxo_db
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
MARIADB_DATABASE: ${DB_DATABASE}
|
||||
MARIADB_USER: ${DB_USERNAME}
|
||||
MARIADB_PASSWORD: ${DB_PASSWORD}
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- ./db_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u root -p$$MARIADB_ROOT_PASSWORD || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 20s
|
||||
|
||||
app:
|
||||
networks:
|
||||
- nexxo
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/php/Dockerfile
|
||||
args:
|
||||
UID: 1000
|
||||
GID: 1000
|
||||
image: nexxo-php
|
||||
container_name: nexxo_app
|
||||
ports:
|
||||
- "5173:5173"
|
||||
restart: unless-stopped
|
||||
working_dir: /var/www/html
|
||||
volumes:
|
||||
- ./src:/var/www/html
|
||||
environment:
|
||||
DB_HOST: db
|
||||
REDIS_HOST: redis
|
||||
XDG_CONFIG_HOME: /tmp
|
||||
PSYSH_CONFIG_DIR: /tmp/psysh
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
networks:
|
||||
- nexxo
|
||||
image: nginx:alpine
|
||||
container_name: nexxo_web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./src:/var/www/html:ro
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
redis:
|
||||
networks:
|
||||
- nexxo
|
||||
image: redis:7-alpine
|
||||
container_name: nexxo_redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
|
||||
mail-worker:
|
||||
networks:
|
||||
- nexxo
|
||||
image: nexxo-php
|
||||
container_name: nexxo_mail_worker
|
||||
restart: unless-stopped
|
||||
env_file: .env # 🔥 DAS HIER
|
||||
volumes:
|
||||
- ./src:/var/www/html
|
||||
working_dir: /var/www/html
|
||||
command: php artisan app:process-mail-queue
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
worker:
|
||||
networks:
|
||||
- nexxo
|
||||
image: nexxo-php
|
||||
container_name: nexxo_worker
|
||||
restart: unless-stopped
|
||||
env_file: .env # 🔥 auch hier
|
||||
volumes:
|
||||
- ./src:/var/www/html
|
||||
working_dir: /var/www/html
|
||||
command: php artisan queue:work --sleep=1 --tries=3 --timeout=120
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
scheduler:
|
||||
image: nexxo-php
|
||||
container_name: nexxo_scheduler
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nexxo
|
||||
volumes:
|
||||
- ./src:/var/www/html
|
||||
environment:
|
||||
DB_HOST: db
|
||||
REDIS_HOST: redis
|
||||
command: >
|
||||
sh -c '
|
||||
until nc -z db 3306; do sleep 1; done;
|
||||
php artisan schedule:work
|
||||
'
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
reverb:
|
||||
networks:
|
||||
- nexxo
|
||||
image: nexxo-php
|
||||
container_name: nexxo_reverb
|
||||
command: php artisan reverb:start --host=0.0.0.0 --port=8080
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./src:/var/www/html
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
ntfy:
|
||||
image: binwiederhier/ntfy
|
||||
container_name: nexxo_ntfy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8082:80"
|
||||
volumes:
|
||||
- ./docker/ntfy/cache:/var/cache/ntfy
|
||||
- ./docker/ntfy/config:/etc/ntfy
|
||||
command:
|
||||
- serve
|
||||
|
||||
|
||||
horizon:
|
||||
networks:
|
||||
- nexxo
|
||||
image: nexxo-php
|
||||
container_name: nexxo_horizon
|
||||
command: php artisan horizon
|
||||
volumes:
|
||||
- ./src:/var/www/html
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
networks:
|
||||
nexxo:
|
||||
driver: bridge
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name app.aziros.com connect.aziros.com api.aziros.com www.aziros.com socket.aziros.com 10.10.90.102;
|
||||
|
||||
root /var/www/html/public;
|
||||
index index.php index.html;
|
||||
|
||||
client_max_body_size 20M;
|
||||
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Hauptrouting (Laravel)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /livewire {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
# PHP Handling
|
||||
location ~ \.php$ {
|
||||
try_files $uri =404;
|
||||
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
|
||||
include fastcgi_params;
|
||||
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
|
||||
fastcgi_param HTTPS on;
|
||||
fastcgi_param HTTP_X_FORWARDED_PROTO https;
|
||||
}
|
||||
|
||||
# Security
|
||||
location ~ /\.ht {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Optional: favicon / robots
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
|
||||
}
|
||||
|
||||
# Default Block (alles andere droppen)
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
return 444;
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name app.aziros.local
|
||||
api.aziros.local
|
||||
socket.aziros.local
|
||||
vite.aziros.local
|
||||
10.10.90.102;
|
||||
|
||||
root /var/www/html/public;
|
||||
index index.php index.html;
|
||||
|
||||
client_max_body_size 20M;
|
||||
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Hauptrouting (Laravel)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /livewire {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
# PHP Handling
|
||||
location ~ \.php$ {
|
||||
try_files $uri =404;
|
||||
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
|
||||
include fastcgi_params;
|
||||
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
|
||||
fastcgi_param HTTPS off;
|
||||
fastcgi_param HTTP_X_FORWARDED_PROTO http;
|
||||
}
|
||||
|
||||
# Security
|
||||
location ~ /\.ht {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Optional: favicon / robots
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
|
||||
}
|
||||
|
||||
# Default Block (alles andere droppen)
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
return 444;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
base-url: "https://notify.aziros.com"
|
||||
listen-http: ":80"
|
||||
web-root: "disable"
|
||||
|
||||
auth-file: "/etc/ntfy/auth.db"
|
||||
auth-default-access: deny-all
|
||||
|
||||
cache-file: "/var/cache/ntfy/cache.db"
|
||||
cache-duration: "24h"
|
||||
|
||||
behind-proxy: true
|
||||
|
||||
upstream-base-url: "https://ntfy.sh"
|
||||
|
||||
#auth-access:
|
||||
# - "nexxo:*:write"
|
||||
# docker exec -it nexxo_ntfy ntfy user add USERNAME
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
FROM php:8.3-fpm
|
||||
|
||||
# ---------- ARG für UID/GID ----------
|
||||
ARG UID=1000
|
||||
ARG GID=1000
|
||||
|
||||
# ---------- Systempakete ----------
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git unzip libzip-dev libicu-dev libpng-dev libonig-dev libxml2-dev \
|
||||
libjpeg62-turbo-dev libfreetype6-dev libwebp-dev libssl-dev locales rsync ffmpeg \
|
||||
nmap iproute2 net-tools netcat-openbsd curl \
|
||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
|
||||
&& docker-php-ext-install -j"$(nproc)" intl zip pdo_mysql gd bcmath opcache pcntl exif \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- Node.js ----------
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
# ---------- Redis Extension ----------
|
||||
RUN pecl install redis && docker-php-ext-enable redis
|
||||
|
||||
# ---------- Composer ----------
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# ---------- PHP Config ----------
|
||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
|
||||
&& echo "memory_limit=512M" >> /usr/local/etc/php/conf.d/memory.ini \
|
||||
&& echo "upload_max_filesize=20M" >> /usr/local/etc/php/conf.d/uploads.ini \
|
||||
&& echo "post_max_size=20M" >> /usr/local/etc/php/conf.d/uploads.ini
|
||||
|
||||
# ---------- PHP-FPM ----------
|
||||
RUN mkdir -p /usr/local/etc/php-fpm.d \
|
||||
&& echo "[www]" > /usr/local/etc/php-fpm.d/www.conf \
|
||||
&& echo "user = www-data" >> /usr/local/etc/php-fpm.d/www.conf \
|
||||
&& echo "group = www-data" >> /usr/local/etc/php-fpm.d/www.conf \
|
||||
&& echo "listen = 9000" >> /usr/local/etc/php-fpm.d/www.conf \
|
||||
&& echo "pm = dynamic" >> /usr/local/etc/php-fpm.d/www.conf \
|
||||
&& echo "pm.max_children = 16" >> /usr/local/etc/php-fpm.d/www.conf \
|
||||
&& echo "pm.start_servers = 4" >> /usr/local/etc/php-fpm.d/www.conf \
|
||||
&& echo "pm.min_spare_servers = 4" >> /usr/local/etc/php-fpm.d/www.conf \
|
||||
&& echo "pm.max_spare_servers = 8" >> /usr/local/etc/php-fpm.d/www.conf \
|
||||
&& echo "pm.max_requests = 500" >> /usr/local/etc/php-fpm.d/www.conf
|
||||
|
||||
# ---------- UID/GID Fix ----------
|
||||
RUN usermod -u ${UID} www-data \
|
||||
&& groupmod -g ${GID} www-data
|
||||
|
||||
# ---------- Working Dir ----------
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# ---------- Cache Ordner ----------
|
||||
RUN mkdir -p /var/www/.cache \
|
||||
/var/www/.npm
|
||||
|
||||
# ---------- Rechte (nur Cache!) ----------
|
||||
RUN chown -R www-data:www-data /var/www/.cache /var/www/.npm
|
||||
|
||||
# ---------- User ----------
|
||||
USER www-data
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[compose.yaml]
|
||||
indent_size = 4
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
APP_NAME="aziros GmbH"
|
||||
APP_ENV=local
|
||||
# Generieren mit: php artisan key:generate
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://app.aziros.local
|
||||
APP_CONNECT_URL=http://connect.aziros.local
|
||||
ASSET_URL=http://app.aziros.local
|
||||
LIVEWIRE_ASSET_URL=http://app.aziros.local
|
||||
|
||||
APP_VERSION=1.0.0
|
||||
|
||||
DOMAIN_APP=app.aziros.local
|
||||
DOMAIN_API=api.aziros.local
|
||||
DOMAIN_CONNECT=connect.aziros.local
|
||||
DOMAIN_WWW=www.aziros.local
|
||||
|
||||
APP_LOCALE=de
|
||||
APP_FALLBACK_LOCALE=de
|
||||
APP_FAKER_LOCALE=de_DE
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=daily
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=nexxo
|
||||
DB_USERNAME=nexxo
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=.aziros.local
|
||||
SESSION_SECURE_COOKIE=false
|
||||
|
||||
BROADCAST_CONNECTION=reverb
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=redis
|
||||
CACHE_PREFIX=az_cache:
|
||||
CACHE_DRIVER=redis
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM_ADDRESS=
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
BROADCAST_DRIVER=reverb
|
||||
|
||||
REVERB_SERVER_PORT=8080
|
||||
REVERB_SERVER_HOST=0.0.0.0
|
||||
|
||||
REVERB_APP_ID=
|
||||
REVERB_APP_KEY=
|
||||
# Generieren mit: php artisan reverb:install
|
||||
REVERB_APP_SECRET=
|
||||
|
||||
REVERB_HOST=reverb
|
||||
REVERB_PORT=8080
|
||||
REVERB_SCHEME=http
|
||||
|
||||
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||
VITE_REVERB_HOST=socket.aziros.local
|
||||
VITE_REVERB_PORT=80
|
||||
VITE_REVERB_SCHEME=ws
|
||||
|
||||
# Von https://dashboard.stripe.com/apikeys
|
||||
STRIPE_KEY=
|
||||
STRIPE_SECRET=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_CURRENCY=eur
|
||||
|
||||
# Von https://platform.openai.com/api-keys
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-4.1
|
||||
|
||||
# Von https://console.cloud.google.com/auth/clients?project=aziros
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_API=
|
||||
GOOGLE_REDIRECT_URI="http://connect.aziros.local/integrations/google/callback"
|
||||
GOOGLE_CALENDAR_WEBHOOK_URL="http://api.aziros.local/webhooks/google-calendar"
|
||||
|
||||
# Von https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps
|
||||
MICROSOFT_CLIENT_ID=
|
||||
MICROSOFT_CLIENT_SECRET=
|
||||
MICROSOFT_TENANT=
|
||||
MICROSOFT_REDIRECT_URI="http://connect.aziros.local/integrations/outlook/callback"
|
||||
|
||||
MAIL_REMINDER_USERNAME=
|
||||
MAIL_REMINDER_PASSWORD=
|
||||
|
||||
MAIL_SYSTEM_USERNAME=
|
||||
MAIL_SYSTEM_PASSWORD=
|
||||
|
||||
MAIL_ARIA_USERNAME=
|
||||
MAIL_ARIA_PASSWORD=
|
||||
|
||||
MAIL_HELLO_USERNAME=
|
||||
MAIL_HELLO_PASSWORD=
|
||||
|
||||
EXPO_TOKEN=
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Vite dev server (hot-reload)
|
||||
php artisan serve # Laravel dev server
|
||||
|
||||
# Build
|
||||
npm run build # Production Vite build
|
||||
|
||||
# Database
|
||||
php artisan migrate
|
||||
php artisan migrate:fresh --seed
|
||||
|
||||
# Testing
|
||||
php artisan test
|
||||
php artisan test --filter=TestName
|
||||
|
||||
# Livewire
|
||||
php artisan livewire:make ComponentName
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Stack:** Laravel 13 + Livewire 4 + Alpine.js + Tailwind CSS 4 + Vite + Laravel Reverb (WebSockets)
|
||||
|
||||
### Livewire Components (`app/Livewire/`)
|
||||
Each feature lives in its own namespace folder with a matching `resources/views/livewire/` blade. Components use `.layout('layouts.app')` in their `render()` method.
|
||||
|
||||
| Namespace | Route | Purpose |
|
||||
|-----------|-------|---------|
|
||||
| `Calendar/WeekCanvas` | `/calendar` | Main calendar view (week/day/month) |
|
||||
| `Calendar/Sidebar` | — | Slide-in event create/edit panel |
|
||||
| `Activities/Index` | `/activities` | Activity log with search, filter, pagination |
|
||||
| `Automation/Index` | `/automations` | Automation rules with slide-in config panel |
|
||||
| `Integration/Index` | `/integrations` | Google Calendar + Outlook OAuth integration cards |
|
||||
| `Settings/Index` | `/settings` | Tab-based settings (profile, security, notifications, SMTP) |
|
||||
| `Contacts/Index` | `/contacts` | Contact management |
|
||||
| `Agent/Index` | `/agent` | AI agent interface |
|
||||
|
||||
### Models (`app/Models/`)
|
||||
- **User** — has `settings` (json column), `timezone`, `locale`, `hasFeature(string $key)` helper
|
||||
- **Event** — calendar events with color (hex), recurrence, user-owned
|
||||
- **Activity** — audit log; use `Activity::log($userId, $type, $title, ...)` static factory; `visual()` returns icon/bg/text; `typeLabel()` returns German label
|
||||
- **Automation** — user automations with `config` (json); `cfg($key, $default)` helper
|
||||
- **CalendarIntegration** — Google/Outlook OAuth tokens; unique on `[user_id, provider]`
|
||||
- **Reminder**, **Contact**, **Plan**, **Feature**, **Subscription** — supporting models
|
||||
|
||||
### Plan / Feature Gating
|
||||
`User::hasFeature('feature_key')` checks the user's active subscription plan against the `features` pivot. Used for Pro-only functionality (e.g., sync modes in integrations).
|
||||
|
||||
### Calendar Views
|
||||
The main calendar is `WeekCanvas.php` / `week-canvas.blade.php`. It renders week, day, and month views with:
|
||||
- `resources/js/calendar-week-canvas.js` — Alpine.js component handling drag, resize, dblclick-to-create
|
||||
- `resources/js/calendar-interact.js` — interact.js integration for resizing
|
||||
- Overlapping event layout: greedy depth/column assignment in the blade's `@php` block
|
||||
- Solid pastel backgrounds: `rgb(255-(255-r)*0.18, ...)` formula (not transparent rgba)
|
||||
|
||||
### OAuth Integration Flow
|
||||
`app/Http/Controllers/Integration/` — full-page redirect controllers (not Livewire). After callback, redirect to `/integrations?connected=google`. The Livewire Integration component reads the `?connected` query param on mount to show success state.
|
||||
|
||||
### Event System (Livewire ↔ Alpine)
|
||||
- `$this->dispatch('sidebar:createEvent', date: ..., time: ...)` — opens sidebar
|
||||
- `$this->dispatch('eventCreated')` — calendar refreshes via `$listeners = ['eventCreated' => '$refresh']`
|
||||
- `$this->dispatch('notify', ['type' => 'success', 'message' => '...'])` — toast notifications
|
||||
- `$this->dispatch('openModal', 'component.name', [...])` — wire-elements/modal
|
||||
|
||||
### Translations
|
||||
`t('key')` is a custom helper (wraps `__()`) used throughout. Language files in `resources/lang/`. Supported locales: `de`, `en` (configured in `config/app.php` under `locales`).
|
||||
|
||||
### Design System
|
||||
Consistent Tailwind patterns across all views:
|
||||
- Cards: `bg-white border border-gray-100 rounded-xl shadow-sm`
|
||||
- Primary color: `indigo-600` / `bg-indigo-50`
|
||||
- Section headings: `text-xl font-semibold text-gray-800`
|
||||
- Badges/chips: `text-[10px] font-medium px-1.5 py-0.5 rounded-md`
|
||||
- Danger: `text-red-500 hover:text-red-600`
|
||||
|
||||
### Authentication
|
||||
Custom auth middleware: `auth.custom` (guests), `guest.custom` (guests), `user` (authenticated users). Not using Laravel's default `auth` middleware.
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||
|
||||
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
|
||||
|
||||
## Agentic Development
|
||||
|
||||
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
|
||||
|
||||
```bash
|
||||
composer require laravel/boost --dev
|
||||
|
||||
php artisan boost:install
|
||||
```
|
||||
|
||||
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\SubscriptionStatus;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckExpiredSubscriptions extends Command
|
||||
{
|
||||
protected $signature = 'subscriptions:check-expired';
|
||||
protected $description = 'Deaktiviert abgelaufene Subscriptions und setzt User auf Free-Plan zurück';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$expired = Subscription::whereIn('status', [
|
||||
SubscriptionStatus::Active->value,
|
||||
SubscriptionStatus::Gifted->value,
|
||||
])
|
||||
->whereNotNull('ends_at')
|
||||
->where('ends_at', '<', now())
|
||||
->with('user')
|
||||
->get();
|
||||
|
||||
$freePlan = Plan::freePlan();
|
||||
|
||||
foreach ($expired as $sub) {
|
||||
$sub->update(['status' => SubscriptionStatus::Expired->value]);
|
||||
|
||||
if ($freePlan && $sub->user) {
|
||||
$sub->user->subscriptions()->create([
|
||||
'plan_id' => $freePlan->id,
|
||||
'plan_name' => $freePlan->name,
|
||||
'plan_slug' => $freePlan->plan_key ?? str($freePlan->name)->slug(),
|
||||
'price' => 0,
|
||||
'interval' => 'monthly',
|
||||
'status' => SubscriptionStatus::Active->value,
|
||||
'starts_at' => now(),
|
||||
'ends_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->info("Subscription von {$sub->user?->name} abgelaufen.");
|
||||
}
|
||||
|
||||
$this->info("{$expired->count()} Subscriptions deaktiviert.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MailQueue;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
#[Signature('mail:cleanup')]
|
||||
#[Description('Command description')]
|
||||
class CleanMailQueue extends Command
|
||||
{
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
// 🔥 SENT (1 Tag)
|
||||
$sent = MailQueue::where('status', 'sent')
|
||||
->where('sent_at', '<', now()->subDay())
|
||||
->delete();
|
||||
|
||||
// 🔥 FAILED (7 Tage)
|
||||
$failed = MailQueue::where('status', 'failed')
|
||||
->where('updated_at', '<', now()->subDays(7))
|
||||
->delete();
|
||||
|
||||
// 🔥 CANCELED (2 Tage)
|
||||
$canceled = MailQueue::where('status', 'canceled')
|
||||
->where('updated_at', '<', now()->subDays(2))
|
||||
->delete();
|
||||
|
||||
// 🔥 STUCK PROCESSING → zurücksetzen
|
||||
$recovered = MailQueue::where('status', 'processing')
|
||||
->where('updated_at', '<', now()->subMinutes(10))
|
||||
->update([
|
||||
'status' => 'pending',
|
||||
'available_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
$this->info("Sent deleted: $sent");
|
||||
$this->info("Failed deleted: $failed");
|
||||
$this->info("Canceled deleted: $canceled");
|
||||
$this->info("Recovered jobs: $recovered");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Verification;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
#[Signature('users:cleanup-deleted')]
|
||||
#[Description('Command description')]
|
||||
class CleanupDeletedUsers extends Command
|
||||
{
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
User::onlyTrashed()
|
||||
->where('deleted_at', '<=', now()->subDays(7))
|
||||
->chunk(100, function ($users) {
|
||||
|
||||
foreach ($users as $user) {
|
||||
$user->anonymize();
|
||||
}
|
||||
});
|
||||
|
||||
Verification::whereNotNull('verified_at')
|
||||
->orWhere('expires_at', '<', now())
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\SubscriptionStatus;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ConsolidateSubscriptions extends Command
|
||||
{
|
||||
protected $signature = 'subscriptions:consolidate {--dry-run : Zeigt was gemacht würde, ohne zu schreiben}';
|
||||
protected $description = 'Konsolidiert mehrere Subscription-Zeilen pro User auf eine einzige';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$freePlan = Plan::freePlan();
|
||||
|
||||
// Alle User mit mehr als einer Subscription
|
||||
$userIds = Subscription::select('user_id')
|
||||
->groupBy('user_id')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->pluck('user_id');
|
||||
|
||||
if ($userIds->isEmpty()) {
|
||||
$this->info('Keine doppelten Subscriptions gefunden.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Betroffene User: {$userIds->count()}");
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
$subs = Subscription::where('user_id', $userId)
|
||||
->orderByRaw("CASE WHEN status = 'active' THEN 0 ELSE 1 END")
|
||||
->orderByRaw("CASE WHEN provider = 'stripe' THEN 0 ELSE 1 END")
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
// Zu behaltende Subscription: aktive Stripe-Sub bevorzugt, sonst neuste aktive, sonst neuste
|
||||
$keep = $subs->first();
|
||||
|
||||
// provider_customer_id aus einer beliebigen Sub retten (auch wenn keep sie nicht hat)
|
||||
$customerId = $subs->whereNotNull('provider_customer_id')->first()?->provider_customer_id;
|
||||
|
||||
$discard = $subs->where('id', '!=', $keep->id);
|
||||
|
||||
$this->line("User {$userId}: behalte {$keep->id} ({$keep->plan_slug}/{$keep->status}), lösche {$discard->count()} Zeile(n)");
|
||||
|
||||
if ($dryRun) continue;
|
||||
|
||||
DB::transaction(function () use ($keep, $discard, $customerId) {
|
||||
// Payments auf die verbleibende Subscription umhängen
|
||||
foreach ($discard as $sub) {
|
||||
Payment::where('subscription_id', $sub->id)
|
||||
->update(['subscription_id' => $keep->id]);
|
||||
}
|
||||
|
||||
// Überschüssige Zeilen löschen
|
||||
Subscription::whereIn('id', $discard->pluck('id'))->delete();
|
||||
|
||||
// customer_id sichern falls keep sie nicht hatte
|
||||
if (!$keep->provider_customer_id && $customerId) {
|
||||
$keep->update(['provider_customer_id' => $customerId]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->info('Konsolidierung abgeschlossen.');
|
||||
} else {
|
||||
$this->warn('Dry-run – keine Änderungen geschrieben. Ohne --dry-run ausführen zum Anwenden.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Affiliate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CreateMissingAffiliates extends Command
|
||||
{
|
||||
protected $signature = 'affiliates:create-missing';
|
||||
protected $description = 'Erstellt Affiliate-Accounts für alle User die noch keinen haben';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$users = User::whereDoesntHave('affiliate')->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
Affiliate::create([
|
||||
'user_id' => $user->id,
|
||||
'code' => Affiliate::generateCode($user),
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$this->info("Affiliate erstellt für {$user->name}: " . $user->affiliate->code);
|
||||
}
|
||||
|
||||
$this->info("{$users->count()} Affiliate-Accounts erstellt.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AffiliateCreditLog;
|
||||
use App\Models\AffiliateReferral;
|
||||
use App\Models\CreditTransaction;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class ProcessAffiliateQualifications extends Command
|
||||
{
|
||||
protected $signature = 'affiliates:process-qualifications';
|
||||
protected $description = 'Schreibt Credits für qualifizierte Referrals gut';
|
||||
|
||||
const CREDITS_PER_REFERRAL = 500;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$qualified = AffiliateReferral::where('status', 'pending')
|
||||
->where('qualifies_at', '<=', now())
|
||||
->with(['affiliate.user', 'referredUser'])
|
||||
->get();
|
||||
|
||||
foreach ($qualified as $referral) {
|
||||
$referredUser = $referral->referredUser;
|
||||
if (!$referredUser || $referredUser->status->value !== 'active') {
|
||||
$referral->update(['status' => 'cancelled']);
|
||||
continue;
|
||||
}
|
||||
|
||||
$affiliate = $referral->affiliate;
|
||||
$credits = self::CREDITS_PER_REFERRAL;
|
||||
|
||||
// Credits via Transaktion gutschreiben
|
||||
CreditTransaction::affiliate(
|
||||
$affiliate->user,
|
||||
$credits,
|
||||
$referredUser->name,
|
||||
$referral->id
|
||||
);
|
||||
|
||||
// Log erstellen
|
||||
AffiliateCreditLog::create([
|
||||
'affiliate_id' => $affiliate->id,
|
||||
'referral_id' => $referral->id,
|
||||
'credits' => $credits,
|
||||
'reason' => '3-Monats-Qualifikation: ' . $referredUser->name,
|
||||
]);
|
||||
|
||||
// Referral als bezahlt markieren
|
||||
$referral->update([
|
||||
'status' => 'paid',
|
||||
'paid_at' => now(),
|
||||
'credits_awarded' => $credits,
|
||||
]);
|
||||
|
||||
// Affiliate-Stats aktualisieren
|
||||
$affiliate->increment('qualified_referrals');
|
||||
$affiliate->increment('total_credits_earned', $credits);
|
||||
|
||||
// Benachrichtigung
|
||||
try {
|
||||
Mail::to($affiliate->user->email)->send(
|
||||
new \App\Mail\AffiliateQualifiedMail(
|
||||
$affiliate->user,
|
||||
$referredUser,
|
||||
$credits
|
||||
)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
$this->info("{$credits} Credits an {$affiliate->user->name} für Referral {$referredUser->name}");
|
||||
}
|
||||
|
||||
$this->info("{$qualified->count()} Referrals verarbeitet.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MailQueue;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class ProcessMailQueue extends Command
|
||||
{
|
||||
protected $signature = 'app:process-mail-queue';
|
||||
protected $description = 'Process mail queue';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
while (true) {
|
||||
|
||||
$jobs = MailQueue::where(function ($q) {
|
||||
$q->where('status', 'pending')
|
||||
->orWhere(function ($q) {
|
||||
$q->where('status', 'processing')
|
||||
->where('updated_at', '<', now()->subMinutes(2));
|
||||
});
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('available_at')
|
||||
->orWhere('available_at', '<=', now());
|
||||
})
|
||||
->orderBy('created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
foreach ($jobs as $mail) {
|
||||
|
||||
// 🔥 ATOMIC LOCK
|
||||
$updated = MailQueue::where('id', $mail->id)
|
||||
->where(function ($q) {
|
||||
$q->where('status', 'pending')
|
||||
->orWhere(function ($q) {
|
||||
$q->where('status', 'processing')
|
||||
->where('updated_at', '<', now()->subMinutes(2));
|
||||
});
|
||||
})
|
||||
->limit(1)
|
||||
->update([
|
||||
'status' => 'processing',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if (!$updated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
app()->setLocale($mail->locale);
|
||||
|
||||
$html = view('emails.' . $mail->template, $mail->meta)->render();
|
||||
|
||||
Mail::html($html, function ($message) use ($mail) {
|
||||
$message->to($mail->to)
|
||||
->subject($mail->subject ?? 'Mail');
|
||||
});
|
||||
|
||||
$mail->update([
|
||||
'sent_at' => now(),
|
||||
'status' => 'sent',
|
||||
'error' => null
|
||||
]);
|
||||
|
||||
usleep(500000);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
$tries = $mail->tries + 1;
|
||||
$delay = 30 * $tries;
|
||||
|
||||
$status = $tries >= $mail->max_tries ? 'failed' : 'pending';
|
||||
|
||||
$mail->update([
|
||||
'tries' => $tries,
|
||||
'status' => $status,
|
||||
'error' => $e->getMessage(),
|
||||
'available_at' => $status === 'pending'
|
||||
? now()->addSeconds($delay)
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 dynamisches sleep
|
||||
if ($jobs->isEmpty()) {
|
||||
usleep(1000000); // idle
|
||||
} else {
|
||||
usleep(200000); // busy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//
|
||||
//namespace App\Console\Commands;
|
||||
//
|
||||
//use App\Models\MailQueue;
|
||||
//use Illuminate\Console\Attributes\Description;
|
||||
//use Illuminate\Console\Attributes\Signature;
|
||||
//use Illuminate\Console\Command;
|
||||
//use Illuminate\Support\Facades\Mail;
|
||||
//
|
||||
//#[Signature('app:process-mail-queue')]
|
||||
//#[Description('Command description')]
|
||||
//class ProcessMailQueue extends Command
|
||||
//{
|
||||
// /**
|
||||
// * Execute the console command.
|
||||
// */
|
||||
// public function handle()
|
||||
// {
|
||||
// while (true) {
|
||||
//
|
||||
// $jobs = MailQueue::where(function ($q) {
|
||||
// $q->where('status', 'pending')
|
||||
// ->orWhere(function ($q) {
|
||||
// $q->where('status', 'processing')
|
||||
// ->where('updated_at', '<', now()->subMinutes(2));
|
||||
// });
|
||||
// })
|
||||
// ->limit(10)
|
||||
// ->orderBy('created_at')
|
||||
// ->get();
|
||||
//
|
||||
// $jobs->each(function ($mail) {
|
||||
//
|
||||
// // 🔥 LOCK setzen
|
||||
// $updated = MailQueue::where('id', $mail->id)
|
||||
// ->where(function ($q) {
|
||||
// $q->where('status', 'pending')
|
||||
// ->orWhere(function ($q) {
|
||||
// $q->where('status', 'processing')
|
||||
// ->where('updated_at', '<', now()->subMinutes(2));
|
||||
// });
|
||||
// })
|
||||
// ->update([
|
||||
// 'status' => 'processing',
|
||||
// 'updated_at' => now(),
|
||||
// ]);
|
||||
//
|
||||
// // wenn schon verarbeitet → skip
|
||||
// if (!$updated) return;
|
||||
//
|
||||
// $mail->refresh();
|
||||
//
|
||||
// try {
|
||||
//
|
||||
// app()->setLocale($mail->locale);
|
||||
//
|
||||
// $html = view('emails.' . $mail->template, $mail->meta)->render();
|
||||
//
|
||||
// Mail::html($html, function ($message) use ($mail) {
|
||||
// $message->to($mail->to)
|
||||
// ->subject($mail->subject ?? 'Mail');
|
||||
// });
|
||||
//
|
||||
// $mail->update([
|
||||
// 'sent_at' => now(),
|
||||
// 'status' => 'sent',
|
||||
// 'error' => null
|
||||
// ]);
|
||||
//
|
||||
// } catch (\Throwable $e) {
|
||||
//
|
||||
// $tries = $mail->tries + 1;
|
||||
// $delay = 30 * $tries;
|
||||
//
|
||||
// $status = $tries >= $mail->max_tries ? 'failed' : 'pending';
|
||||
//
|
||||
// $mail->update([
|
||||
// 'tries' => $tries,
|
||||
// 'status' => $status,
|
||||
// 'error' => $e->getMessage(),
|
||||
// 'available_at' => $status === 'pending'
|
||||
// ? now()->addSeconds($delay)
|
||||
// : null,
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// });
|
||||
//
|
||||
// if ($jobs->isEmpty()) {
|
||||
// usleep(1000000); // 1s
|
||||
// } else {
|
||||
// usleep(200000); // 0.2s
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CreditTransaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ProcessMonthlyCredits extends Command
|
||||
{
|
||||
protected $signature = 'credits:monthly-reset';
|
||||
protected $description = 'Bucht Bonus-Verbrauch ab für den Vormonat';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$lastMonth = now()->subMonth();
|
||||
$processed = 0;
|
||||
|
||||
User::whereHas('subscription', fn($q) =>
|
||||
$q->whereIn('status', ['active', 'gifted'])
|
||||
)->each(function (User $user) use ($lastMonth, &$processed) {
|
||||
|
||||
$planLimit = $user->subscription?->plan?->credit_limit ?? 0;
|
||||
|
||||
// Unlimited → nichts tun
|
||||
if ($planLimit === 0) return;
|
||||
|
||||
// Verbrauch letzten Monat
|
||||
$usage = (int) $user->agentLogs()
|
||||
->whereYear('created_at', $lastMonth->year)
|
||||
->whereMonth('created_at', $lastMonth->month)
|
||||
->sum('credits');
|
||||
|
||||
// Unter Plan-Limit → kein Bonus-Abzug
|
||||
if ($usage <= $planLimit) return;
|
||||
|
||||
$overUsage = $usage - $planLimit;
|
||||
|
||||
// Verfügbares Guthaben prüfen
|
||||
$bonusLeft = max(0, (int) $user->creditTransactions()->sum('amount'));
|
||||
|
||||
if ($bonusLeft <= 0) return;
|
||||
|
||||
$deduct = min($overUsage, $bonusLeft);
|
||||
|
||||
CreditTransaction::create([
|
||||
'user_id' => $user->id,
|
||||
'amount' => -$deduct,
|
||||
'type' => 'usage',
|
||||
'description' => 'Bonus-Verbrauch ' . $lastMonth->translatedFormat('M Y') . ' (' . $overUsage . ' über Plan-Limit)',
|
||||
]);
|
||||
|
||||
$processed++;
|
||||
$this->info("-{$deduct} Credits von {$user->name} ({$usage}/{$planLimit}, {$overUsage} über Limit)");
|
||||
});
|
||||
|
||||
$this->info("{$processed} Bonus-Abbuchungen verarbeitet.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\PushService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RunAutomations extends Command
|
||||
{
|
||||
protected $signature = 'automations:run';
|
||||
|
||||
protected $description = 'Führt fällige Automationen aus (läuft jede Minute)';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->runBirthdayReminders();
|
||||
}
|
||||
|
||||
private function runBirthdayReminders(): void
|
||||
{
|
||||
$users = User::whereHas('automations', fn ($q) => $q
|
||||
->where('type', 'birthday_reminder')
|
||||
->where('active', true)
|
||||
)->with(['automations' => fn ($q) => $q->where('type', 'birthday_reminder')])->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
$auto = $user->automations->first();
|
||||
|
||||
$settings = [];
|
||||
if (!empty($auto->settings)) {
|
||||
$settings = is_array($auto->settings)
|
||||
? $auto->settings
|
||||
: json_decode($auto->settings, true);
|
||||
} elseif (!empty($auto->config)) {
|
||||
$settings = is_array($auto->config)
|
||||
? $auto->config
|
||||
: json_decode($auto->config, true);
|
||||
}
|
||||
|
||||
$daysBefore = (int) ($settings['days_before'] ?? 0);
|
||||
$tz = $user->timezone ?? 'Europe/Vienna';
|
||||
$targetMonthDay = now($tz)->addDays($daysBefore)->format('m-d');
|
||||
|
||||
$contacts = \App\Models\Contact::where('user_id', $user->id)
|
||||
->whereNotNull('birthday')
|
||||
->get()
|
||||
->filter(fn ($c) => Carbon::parse($c->birthday)->format('m-d') === $targetMonthDay);
|
||||
|
||||
foreach ($contacts as $contact) {
|
||||
$hash = md5($user->id . $contact->id . now($tz)->format('Y-m-d') . 'birthday');
|
||||
|
||||
$alreadySent = DB::table('sent_reminders')
|
||||
->where('reminder_hash', $hash)
|
||||
->where('type', 'birthday')
|
||||
->whereDate('sent_at', now($tz)->toDateString())
|
||||
->exists();
|
||||
|
||||
if ($alreadySent) continue;
|
||||
|
||||
$pushText = [
|
||||
'title' => $daysBefore === 0
|
||||
? "Heute: {$contact->name} hat Geburtstag!"
|
||||
: "Geburtstag: {$contact->name}",
|
||||
'body' => $daysBefore === 0
|
||||
? 'Vergiss nicht zu gratulieren!'
|
||||
: "In {$daysBefore} Tag(en)",
|
||||
];
|
||||
|
||||
try {
|
||||
PushService::send(
|
||||
$user,
|
||||
$pushText['title'],
|
||||
$pushText['body'],
|
||||
['type' => 'birthday_reminder', 'contact_id' => $contact->id]
|
||||
);
|
||||
|
||||
DB::table('sent_reminders')->insert([
|
||||
'id' => (string) Str::uuid(),
|
||||
'event_id' => null,
|
||||
'reminder_hash' => $hash,
|
||||
'type' => 'birthday',
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
\Log::info('Birthday push sent', [
|
||||
'user' => $user->name,
|
||||
'contact' => $contact->name,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Birthday push failed', [
|
||||
'user' => $user->name,
|
||||
'contact' => $contact->name,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Event;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Task;
|
||||
use App\Services\PushService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ScheduleEventReminders extends Command
|
||||
{
|
||||
protected $signature = 'reminders:schedule';
|
||||
|
||||
protected $description = 'Dispatcht fällige Event-Reminder (läuft jede Minute)';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$now = now();
|
||||
$minuteStart = $now->copy()->startOfMinute();
|
||||
$minuteEnd = $now->copy()->endOfMinute();
|
||||
|
||||
$this->processTaskReminders($minuteStart, $minuteEnd);
|
||||
|
||||
$events = Event::with('user')
|
||||
->where('starts_at', '>', $now)
|
||||
->where('starts_at', '<', $now->copy()->addDay())
|
||||
->get();
|
||||
|
||||
Log::info('reminders:schedule run', [
|
||||
'now_utc' => $now->toDateTimeString(),
|
||||
'window_utc' => $minuteEnd->toDateTimeString(),
|
||||
'events_found' => $events->count(),
|
||||
]);
|
||||
|
||||
foreach ($events as $event) {
|
||||
$user = $event->user;
|
||||
if (!$user) continue;
|
||||
|
||||
$tz = $user->timezone ?? 'Europe/Vienna';
|
||||
|
||||
// ══════════════════════════════════════
|
||||
// PFAD A — Event hat eigene Reminder
|
||||
// ══════════════════════════════════════
|
||||
$eventReminders = $event->reminders ?? [];
|
||||
if (is_string($eventReminders)) {
|
||||
$eventReminders = json_decode($eventReminders, true) ?? [];
|
||||
}
|
||||
|
||||
if (!empty($eventReminders)) {
|
||||
foreach ($eventReminders as $reminder) {
|
||||
$sendAt = $this->calculateSendTime($event, $reminder, $tz);
|
||||
|
||||
if (!$sendAt || !$sendAt->between($minuteStart, $minuteEnd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = md5(json_encode($reminder));
|
||||
$sent = DB::table('sent_reminders')
|
||||
->where('event_id', $event->id)
|
||||
->where('reminder_hash', $hash)
|
||||
->exists();
|
||||
if ($sent) continue;
|
||||
|
||||
$this->sendEventReminder($user, $event, $reminder, $tz);
|
||||
|
||||
DB::table('sent_reminders')->insert([
|
||||
'id' => (string) Str::uuid(),
|
||||
'event_id' => $event->id,
|
||||
'reminder_hash' => $hash,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════
|
||||
// PFAD B — Globale Automation
|
||||
// ══════════════════════════════════════
|
||||
$globalAuto = $user->automations()
|
||||
->where('type', 'event_reminder')
|
||||
->where('active', true)
|
||||
->first();
|
||||
|
||||
if (!$globalAuto) continue;
|
||||
|
||||
$minutesBefore = $globalAuto->cfg('minutes_before', 30);
|
||||
$reminder = [
|
||||
'type' => 'before',
|
||||
'minutes' => $minutesBefore,
|
||||
];
|
||||
|
||||
$sendAt = $this->calculateSendTime($event, $reminder, $tz);
|
||||
|
||||
if (!$sendAt || !$sendAt->between($minuteStart, $minuteEnd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = md5('global_' . $minutesBefore);
|
||||
$sent = DB::table('sent_reminders')
|
||||
->where('event_id', $event->id)
|
||||
->where('reminder_hash', $hash)
|
||||
->exists();
|
||||
if ($sent) continue;
|
||||
|
||||
$this->sendEventReminder($user, $event, $reminder, $tz);
|
||||
|
||||
DB::table('sent_reminders')->insert([
|
||||
'id' => (string) Str::uuid(),
|
||||
'event_id' => $event->id,
|
||||
'reminder_hash' => $hash,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendEventReminder(\App\Models\User $user, Event $event, array $reminder, string $tz): void
|
||||
{
|
||||
$start = $event->starts_at->setTimezone($tz);
|
||||
$diffMin = (int) now($tz)->diffInMinutes($start, false);
|
||||
|
||||
$body = match (true) {
|
||||
$diffMin <= 0 => 'Jetzt!',
|
||||
$diffMin < 60 => 'In ' . $diffMin . ' Minuten',
|
||||
$diffMin < 1440 => 'In ' . intval($diffMin / 60) . ' Stunde(n)',
|
||||
default => 'Morgen um ' . $start->format('H:i') . ' Uhr',
|
||||
};
|
||||
|
||||
Log::info('Event push sending', [
|
||||
'event' => $event->title,
|
||||
'diff_min' => $diffMin,
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$hasPushToken = Device::where('user_id', $user->id)
|
||||
->where('active', true)
|
||||
->whereNotNull('push_token')
|
||||
->exists();
|
||||
|
||||
if ($hasPushToken) {
|
||||
PushService::send(
|
||||
$user,
|
||||
'Erinnerung: ' . $event->title,
|
||||
$body,
|
||||
['type' => 'event_reminder', 'event_id' => $event->id]
|
||||
);
|
||||
}
|
||||
|
||||
Notification::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'event_reminder',
|
||||
'title' => $event->title,
|
||||
'message' => $body,
|
||||
'data' => ['event_id' => $event->id, 'reminder' => $reminder],
|
||||
]);
|
||||
|
||||
Log::info('Event reminder sent', ['user' => $user->name, 'event' => $event->title]);
|
||||
}
|
||||
|
||||
private function processTaskReminders(Carbon $minuteStart, Carbon $minuteEnd): void
|
||||
{
|
||||
$tasks = Task::with('user')
|
||||
->whereNotNull('reminder_at')
|
||||
->where('reminder_at', '>=', $minuteStart)
|
||||
->where('reminder_at', '<=', $minuteEnd)
|
||||
->where('reminder_sent', false)
|
||||
->where('status', '!=', 'done')
|
||||
->get();
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
$user = $task->user;
|
||||
if (!$user) continue;
|
||||
|
||||
$tz = $user->timezone ?? 'Europe/Vienna';
|
||||
|
||||
$body = $task->due_at
|
||||
? 'Fällig: ' . $task->due_at->setTimezone($tz)->format('H:i') . ' Uhr'
|
||||
: 'Aufgabe erledigen';
|
||||
|
||||
$hasPushToken = Device::where('user_id', $user->id)
|
||||
->where('active', true)
|
||||
->whereNotNull('push_token')
|
||||
->exists();
|
||||
|
||||
if ($hasPushToken) {
|
||||
PushService::send(
|
||||
$user,
|
||||
'Erinnerung: ' . $task->title,
|
||||
$body,
|
||||
['type' => 'task_reminder', 'task_id' => $task->id]
|
||||
);
|
||||
}
|
||||
|
||||
Notification::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'task_reminder',
|
||||
'title' => $task->title,
|
||||
'message' => $body,
|
||||
'data' => ['task_id' => $task->id],
|
||||
]);
|
||||
|
||||
$task->update(['reminder_sent' => true]);
|
||||
|
||||
Log::info('Task reminder sent', ['user' => $user->name, 'task' => $task->title]);
|
||||
}
|
||||
}
|
||||
|
||||
private function calculateSendTime(Event $event, array $reminder, string $tz): ?Carbon
|
||||
{
|
||||
$startUtc = $event->starts_at;
|
||||
|
||||
$sendAt = match ($reminder['type']) {
|
||||
'before' => $startUtc->copy()->subMinutes($reminder['minutes'] ?? 30),
|
||||
|
||||
'time_of_day' => Carbon::createFromFormat(
|
||||
'Y-m-d H:i',
|
||||
$event->starts_at->setTimezone($tz)->format('Y-m-d') . ' ' . ($reminder['time'] ?? '08:00'),
|
||||
$tz
|
||||
)->utc(),
|
||||
|
||||
'day_before' => Carbon::createFromFormat(
|
||||
'Y-m-d H:i',
|
||||
$event->starts_at->setTimezone($tz)->subDay()->format('Y-m-d') . ' ' . ($reminder['time'] ?? '18:00'),
|
||||
$tz
|
||||
)->utc(),
|
||||
|
||||
default => null,
|
||||
};
|
||||
|
||||
Log::info('calculateSendTime', [
|
||||
'event' => $event->title,
|
||||
'reminder' => $reminder,
|
||||
'starts_utc' => $startUtc->toDateTimeString(),
|
||||
'send_at_utc' => $sendAt?->toDateTimeString(),
|
||||
'now_utc' => now()->toDateTimeString(),
|
||||
'diff_min' => $sendAt ? round(now()->diffInSeconds($sendAt, false) / 60, 2) : null,
|
||||
]);
|
||||
|
||||
return $sendAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PaymentStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Paid = 'paid';
|
||||
case Failed = 'failed';
|
||||
case Refunded = 'refunded';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::Pending => 'Ausstehend',
|
||||
self::Paid => 'Bezahlt',
|
||||
self::Failed => 'Fehlgeschlagen',
|
||||
self::Refunded => 'Erstattet',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeBg(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::Pending => 'bg-amber-50',
|
||||
self::Paid => 'bg-green-50',
|
||||
self::Failed => 'bg-red-50',
|
||||
self::Refunded => 'bg-blue-50',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeText(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::Pending => 'text-amber-700',
|
||||
self::Paid => 'text-green-700',
|
||||
self::Failed => 'text-red-700',
|
||||
self::Refunded => 'text-blue-700',
|
||||
};
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::Pending => 'clock',
|
||||
self::Paid => 'check',
|
||||
self::Failed => 'x-mark',
|
||||
self::Refunded => 'arrow-uturn-left',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum SubscriptionStatus: string
|
||||
{
|
||||
case Active = 'active';
|
||||
case Canceled = 'canceled';
|
||||
case Expired = 'expired';
|
||||
case PastDue = 'past_due';
|
||||
case Gifted = 'gifted';
|
||||
case Superseded = 'superseded';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::Active => 'Aktiv',
|
||||
self::Canceled => 'Gekündigt',
|
||||
self::Expired => 'Abgelaufen',
|
||||
self::PastDue => 'Zahlung offen',
|
||||
self::Gifted => 'Geschenkt',
|
||||
self::Superseded => 'Ersetzt',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeBg(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::Active => 'bg-green-50',
|
||||
self::Canceled => 'bg-amber-50',
|
||||
self::Expired => 'bg-red-50',
|
||||
self::PastDue => 'bg-red-50',
|
||||
self::Gifted => 'bg-purple-50',
|
||||
self::Superseded => 'bg-gray-50',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeText(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::Active => 'text-green-700',
|
||||
self::Canceled => 'text-amber-700',
|
||||
self::Expired => 'text-red-700',
|
||||
self::PastDue => 'text-red-700',
|
||||
self::Gifted => 'text-purple-700',
|
||||
self::Superseded => 'text-gray-500',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum UserRole: string
|
||||
{
|
||||
case User = 'user';
|
||||
case BetaTester = 'beta_tester';
|
||||
case Affiliate = 'affiliate';
|
||||
case Support = 'support';
|
||||
case Developer = 'developer';
|
||||
case Admin = 'admin';
|
||||
case SuperAdmin = 'super_admin';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::User => 'Nutzer',
|
||||
self::BetaTester => 'Beta-Tester',
|
||||
self::Affiliate => 'Affiliate',
|
||||
self::Support => 'Support',
|
||||
self::Developer => 'Developer',
|
||||
self::Admin => 'Administrator',
|
||||
self::SuperAdmin => 'Super Admin',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeBg(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::User => 'bg-gray-100',
|
||||
self::BetaTester => 'bg-orange-50',
|
||||
self::Affiliate => 'bg-fuchsia-50',
|
||||
self::Support => 'bg-blue-50',
|
||||
self::Developer => 'bg-green-50',
|
||||
self::Admin => 'bg-purple-50',
|
||||
self::SuperAdmin => 'bg-yellow-50',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeText(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::User => 'text-gray-500',
|
||||
self::BetaTester => 'text-orange-700',
|
||||
self::Affiliate => 'text-fuchsia-700',
|
||||
self::Support => 'text-blue-700',
|
||||
self::Developer => 'text-green-700',
|
||||
self::Admin => 'text-purple-700',
|
||||
self::SuperAdmin => 'text-yellow-700',
|
||||
};
|
||||
}
|
||||
|
||||
public function hierarchy(): int
|
||||
{
|
||||
return match($this) {
|
||||
self::User => 0,
|
||||
self::BetaTester => 1,
|
||||
self::Affiliate => 1,
|
||||
self::Support => 2,
|
||||
self::Developer => 3,
|
||||
self::Admin => 4,
|
||||
self::SuperAdmin => 5,
|
||||
};
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::User => 'user',
|
||||
self::BetaTester => 'beaker',
|
||||
self::Affiliate => 'link',
|
||||
self::Support => 'chat-bubble-left-right',
|
||||
self::Developer => 'code-bracket',
|
||||
self::Admin => 'cog-6-tooth',
|
||||
self::SuperAdmin => 'shield-check',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum UserStatus: string
|
||||
{
|
||||
case Active = 'active';
|
||||
case Blocked = 'blocked';
|
||||
case Suspended = 'suspended';
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CalendarUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $userId
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("calendar.{$this->userId}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'calendar.updated';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class NotificationCreated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Notification $notification,
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('notifications.' . $this->notification->user_id),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'notification.created';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->notification->id,
|
||||
'type' => $this->notification->type,
|
||||
'title' => $this->notification->title,
|
||||
'message' => $this->notification->message,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
|
||||
function t($key, $replace = [], $locale = null)
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
|
||||
$translations = cache()->rememberForever(
|
||||
"translations:$locale",
|
||||
function () use ($locale) {
|
||||
return \App\Models\Translation::where('locale', $locale)
|
||||
->pluck('value', 'key')
|
||||
->toArray();
|
||||
}
|
||||
);
|
||||
|
||||
$text = $translations[$key] ?? $key;
|
||||
|
||||
foreach ($replace as $k => $v) {
|
||||
$text = str_replace(":$k", $v, $text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
//if (!function_exists('t')) {
|
||||
// function t($key, $locale = null)
|
||||
// {
|
||||
// $locale = $locale ?? app()->getLocale();
|
||||
//
|
||||
// $translations = cache()->rememberForever(
|
||||
// "translations:$locale",
|
||||
// function () use ($locale) {
|
||||
// return \App\Models\Translation::where('locale', $locale)
|
||||
// ->pluck('value', 'key')
|
||||
// ->toArray();
|
||||
// }
|
||||
// );
|
||||
//
|
||||
// return $translations[$key] ?? $key;
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AgentLog;
|
||||
use App\Services\AgentAIService;
|
||||
use App\Services\AgentActionService;
|
||||
use App\Services\AgentContextService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AgentChatController extends Controller
|
||||
{
|
||||
public function chat(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'message' => 'required|string|max:2000',
|
||||
'conversation_history' => 'array',
|
||||
'conversation_history.*.role' => 'required_with:conversation_history|in:user,assistant',
|
||||
'conversation_history.*.content' => 'required_with:conversation_history|string',
|
||||
'with_audio' => 'boolean',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$user->load('subscription.plan');
|
||||
|
||||
// Credit-Limit prüfen
|
||||
if ($user->effective_limit > 0 && $user->monthly_usage >= $user->effective_limit * 1.05) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Dein monatliches Credit-Limit ist erreicht.',
|
||||
'errors' => ['credits' => 'Limit überschritten'],
|
||||
], 429);
|
||||
}
|
||||
|
||||
$aiConfig = $user->subscription?->plan?->ai_config ?? [
|
||||
'model' => config('services.openai.model', 'gpt-4o-mini'),
|
||||
'temperature' => 0.5,
|
||||
'max_tokens' => 1500,
|
||||
'input_cost' => 0.00015,
|
||||
'output_cost' => 0.0006,
|
||||
];
|
||||
|
||||
// Konversation aufbauen
|
||||
$history = $request->input('conversation_history', []);
|
||||
$history[] = ['role' => 'user', 'content' => $request->message];
|
||||
|
||||
// User-Kontext erstellen (geteilter Service → Web + API identisch)
|
||||
$userContext = app(AgentContextService::class)->build($user);
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
// AI aufrufen
|
||||
\Log::info('AgentChat: Calling AI', [
|
||||
'user_id' => $user->id,
|
||||
'message' => mb_substr($request->message, 0, 100),
|
||||
'history_count' => count($history),
|
||||
'model' => $aiConfig['model'] ?? 'default',
|
||||
]);
|
||||
|
||||
try {
|
||||
$aiResult = AgentAIService::chat($history, $aiConfig, $userContext);
|
||||
\Log::info('AgentChat: AI result', [
|
||||
'type' => $aiResult['type'] ?? 'unknown',
|
||||
'has_message' => !empty($aiResult['data']['message'] ?? $aiResult['message'] ?? ''),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('AgentChat: AI exception', [
|
||||
'error' => $e->getMessage(),
|
||||
'class' => get_class($e),
|
||||
]);
|
||||
report($e);
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'message' => 'Entschuldigung, ich konnte gerade nicht antworten. Bitte versuche es nochmal.',
|
||||
'action' => null,
|
||||
'type' => 'chat',
|
||||
'credits_used' => 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// _error-Flag: OpenAI API down oder Timeout — 0 Credits, Fehler loggen
|
||||
if (!empty($aiResult['_error'])) {
|
||||
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
AgentLog::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'chat',
|
||||
'input' => mb_substr($request->message, 0, 100),
|
||||
'status' => 'error',
|
||||
'output' => null,
|
||||
'credits' => 0,
|
||||
'ai_response' => null,
|
||||
'model' => $aiConfig['model'] ?? null,
|
||||
'duration_ms' => $durationMs,
|
||||
'prompt_tokens' => 0,
|
||||
'completion_tokens' => 0,
|
||||
'total_tokens' => 0,
|
||||
'cost_usd' => 0,
|
||||
]);
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'message' => $aiResult['data']['message'] ?? 'Entschuldigung, bitte versuche es nochmal.',
|
||||
'action' => null,
|
||||
'type' => 'chat',
|
||||
'credits_used' => 0,
|
||||
'usage' => [
|
||||
'credits_used' => $user->monthly_usage,
|
||||
'credits_limit' => $user->effective_limit,
|
||||
'usage_percent' => $user->usage_percent,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// AgentAIService gibt direkt das parsed-Objekt zurück (kein 'parsed'-Wrapper)
|
||||
// Keys: type, data, _usage, _multi (bei Multi-Actions)
|
||||
$parsed = $aiResult;
|
||||
$usage = $aiResult['_usage'] ?? [];
|
||||
|
||||
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
||||
$actionResult = null;
|
||||
$assistantMessage = $parsed['data']['message']
|
||||
?? $parsed['message']
|
||||
?? $parsed['text']
|
||||
?? '';
|
||||
|
||||
// Aktion ausführen falls vorhanden
|
||||
try {
|
||||
if (isset($parsed['_multi'])) {
|
||||
$actionService = new AgentActionService();
|
||||
$results = [];
|
||||
foreach ($parsed['_multi'] as $action) {
|
||||
$results[] = $actionService->handle($user, $action);
|
||||
}
|
||||
$actionResult = [
|
||||
'status' => collect($results)->every(fn ($r) => $r['status'] === 'success') ? 'success' : 'partial',
|
||||
'results' => $results,
|
||||
];
|
||||
} elseif (isset($parsed['type']) && $parsed['type'] !== 'chat') {
|
||||
$actionService = new AgentActionService();
|
||||
$actionResult = $actionService->handle($user, $parsed);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$actionResult = ['status' => 'error', 'message' => 'Aktion fehlgeschlagen'];
|
||||
}
|
||||
|
||||
// Wenn Aktion ausgeführt aber keine Message von der AI → Action-Message verwenden
|
||||
if (empty($assistantMessage) && $actionResult) {
|
||||
$assistantMessage = $actionResult['message'] ?? 'Erledigt!';
|
||||
}
|
||||
|
||||
// Letzter Schutz: JSON darf NIE vorgelesen werden
|
||||
if (is_string($assistantMessage) && $assistantMessage !== '') {
|
||||
$trim = ltrim($assistantMessage);
|
||||
$isJsonLike = $trim !== '' && ($trim[0] === '{' || $trim[0] === '[')
|
||||
|| str_contains($assistantMessage, '"type":')
|
||||
|| str_contains($assistantMessage, '"data":');
|
||||
|
||||
if ($isJsonLike) {
|
||||
\Log::warning('AgentChat: JSON leaked to assistant message — replaced', [
|
||||
'preview' => mb_substr($assistantMessage, 0, 200),
|
||||
]);
|
||||
$assistantMessage = $actionResult['message'] ?? 'Erledigt!';
|
||||
}
|
||||
}
|
||||
|
||||
// Credits berechnen — Flat-Rate-Logik
|
||||
// - Aktionen: tokenbasiert (wie bisher)
|
||||
// - Erster Chat einer Session (history leer): pauschal 5 Credits
|
||||
// - Folge-Chat-Nachrichten: 0 Credits, kein Log
|
||||
$type = $parsed['type'] ?? 'chat';
|
||||
$isAction = $type !== 'chat';
|
||||
$historyCount = count($request->input('conversation_history', []));
|
||||
$shouldLog = true;
|
||||
|
||||
if ($isAction) {
|
||||
$credits = (($actionResult['status'] ?? '') === 'error')
|
||||
? 0
|
||||
: $this->calculateCredits($usage, $aiConfig, $type);
|
||||
} elseif ($historyCount === 0) {
|
||||
$credits = 5;
|
||||
} else {
|
||||
$credits = 0;
|
||||
$shouldLog = false;
|
||||
}
|
||||
|
||||
if ($shouldLog) {
|
||||
// Input für Log bestimmen (wie im Web)
|
||||
$logInput = match ($type) {
|
||||
'chat' => 'Konversation',
|
||||
'event', 'event_update' => $parsed['data']['title'] ?? $request->message,
|
||||
'note', 'note_update' => mb_substr($parsed['data']['content'] ?? $request->message, 0, 100),
|
||||
'task', 'task_update' => $parsed['data']['title'] ?? $request->message,
|
||||
'contact' => $parsed['data']['name'] ?? $request->message,
|
||||
'email' => 'E-Mail an ' . ($parsed['data']['contact'] ?? '') . ': ' . mb_substr($parsed['data']['subject'] ?? '', 0, 50),
|
||||
'multi' => $request->message,
|
||||
default => mb_substr($request->message, 0, 200),
|
||||
};
|
||||
|
||||
$logStatus = ($actionResult['status'] ?? 'success') === 'error' ? 'failed' : ($actionResult['status'] ?? 'success');
|
||||
|
||||
AgentLog::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => $type,
|
||||
'input' => $logInput,
|
||||
'status' => $logStatus,
|
||||
'output' => $actionResult['meta'] ?? $actionResult ?? null,
|
||||
'credits' => $credits,
|
||||
'ai_response' => $parsed,
|
||||
'model' => $aiConfig['model'] ?? null,
|
||||
'duration_ms' => $durationMs,
|
||||
'prompt_tokens' => $usage['prompt_tokens'] ?? 0,
|
||||
'completion_tokens' => $usage['completion_tokens'] ?? 0,
|
||||
'total_tokens' => $usage['total_tokens'] ?? 0,
|
||||
'cost_usd' => $this->calculateCostUsd($usage, $aiConfig),
|
||||
]);
|
||||
}
|
||||
|
||||
// TTS falls gewünscht (Fehler darf Chat nicht blockieren)
|
||||
$audio = null;
|
||||
if ($request->boolean('with_audio') && $assistantMessage) {
|
||||
try {
|
||||
$audio = AgentAIService::textToSpeech($assistantMessage, $aiConfig);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage neu berechnen
|
||||
$user->refresh();
|
||||
|
||||
$responseData = [
|
||||
'message' => $assistantMessage,
|
||||
'action' => $actionResult,
|
||||
'type' => $parsed['type'] ?? 'chat',
|
||||
'credits_used' => $credits,
|
||||
'usage' => [
|
||||
'credits_used' => $user->monthly_usage,
|
||||
'credits_limit' => $user->effective_limit,
|
||||
'usage_percent' => $user->usage_percent,
|
||||
],
|
||||
];
|
||||
|
||||
if ($audio) {
|
||||
$responseData['audio'] = $audio;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $responseData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function synthesize(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'text' => 'required|string|max:500',
|
||||
'locale' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$aiConfig = $user->subscription?->plan?->ai_config ?? [];
|
||||
if (is_string($aiConfig)) {
|
||||
$aiConfig = json_decode($aiConfig, true);
|
||||
}
|
||||
|
||||
// Voice je nach Locale wählen
|
||||
$voice = str_starts_with($request->input('locale', 'de'), 'en')
|
||||
? 'shimmer'
|
||||
: 'nova';
|
||||
|
||||
$overrideConfig = array_merge($aiConfig, ['tts_voice' => $voice]);
|
||||
$audio = AgentAIService::textToSpeech($request->text, $overrideConfig);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => ['audio' => $audio],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
private function calculateCredits(array $usage, array $aiConfig, string $type): int
|
||||
{
|
||||
// Flat-Basis + Output-Tokens. Kontext (prompt_tokens) wird ignoriert,
|
||||
// damit Credits nicht mit Kalendergröße skalieren. Cap bei 100.
|
||||
$completionTokens = (int) ($usage['completion_tokens'] ?? 0);
|
||||
$credits = 20 + (int) ceil($completionTokens * 0.3);
|
||||
|
||||
return min(100, max(1, $credits));
|
||||
}
|
||||
|
||||
private function calculateCostUsd(array $usage, array $aiConfig): float
|
||||
{
|
||||
$inputCost = $aiConfig['input_cost'] ?? 0.00015;
|
||||
$outputCost = $aiConfig['output_cost'] ?? 0.0006;
|
||||
|
||||
return (($usage['prompt_tokens'] ?? 0) / 1000) * $inputCost
|
||||
+ (($usage['completion_tokens'] ?? 0) / 1000) * $outputCost;
|
||||
}
|
||||
|
||||
public function logs(Request $request): JsonResponse
|
||||
{
|
||||
$logs = $request->user()
|
||||
->agentLogs()
|
||||
->latest()
|
||||
->take(20)
|
||||
->get(['id', 'type', 'input', 'output', 'status', 'credits', 'duration_ms', 'created_at']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $logs,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\User;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
if (! $user || ! Hash::check($request->password, $user->password)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Die Anmeldedaten sind ungültig.',
|
||||
'errors' => [],
|
||||
], 401);
|
||||
}
|
||||
|
||||
$token = Str::random(64);
|
||||
$user->tokens()->create([
|
||||
'token' => hash('sha256', $token),
|
||||
'name' => $request->header('User-Agent', 'API'),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'token' => $token,
|
||||
'user' => $user->only(['id', 'name', 'email', 'locale', 'timezone']),
|
||||
],
|
||||
'message' => 'Erfolgreich angemeldet.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$bearer = $request->bearerToken();
|
||||
|
||||
if ($bearer) {
|
||||
$request->user()->tokens()
|
||||
->where('token', hash('sha256', $bearer))
|
||||
->delete();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => null,
|
||||
'message' => 'Erfolgreich abgemeldet.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$user->load('subscription.plan');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'id' => $user->id,
|
||||
'locale' => $user->locale ?? 'de',
|
||||
'timezone' => $user->timezone ?? 'Europe/Vienna',
|
||||
'user' => $user->only(['id', 'name', 'email', 'locale', 'timezone', 'settings', 'role']),
|
||||
'plan' => $user->subscription?->plan?->only(['id', 'name', 'plan_key', 'credit_limit']),
|
||||
'usage' => [
|
||||
'credits_used' => $user->monthly_usage,
|
||||
'credits_limit' => $user->effective_limit,
|
||||
'usage_percent' => $user->usage_percent,
|
||||
'bonus_credits' => $user->bonus_credits,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Automation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AutomationController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$isPro = $user->isInternalUser() || $user->hasFeature('automations');
|
||||
$types = config('automations.types', []);
|
||||
|
||||
\Log::info('Automation index', [
|
||||
'user_id' => $user->id,
|
||||
'plan_key' => $user->subscription?->plan?->key ?? 'none',
|
||||
'is_pro' => $isPro,
|
||||
'free_types' => config('automations.free_types', []),
|
||||
]);
|
||||
|
||||
$userAutomations = $user->automations()->get()->keyBy('type');
|
||||
|
||||
$locale = $user->locale ?? 'de';
|
||||
|
||||
$result = collect($types)
|
||||
->map(function (array $def, string $type) use ($userAutomations, $isPro, $locale) {
|
||||
$userAuto = $userAutomations->get($type);
|
||||
$isFree = (bool) ($def['is_free'] ?? false);
|
||||
$name = t("automations.type.{$type}.name", [], $locale);
|
||||
$desc = t("automations.type.{$type}.description", [], $locale);
|
||||
return [
|
||||
'type' => $type,
|
||||
'name' => ($name !== "automations.type.{$type}.name") ? $name : $def['name'],
|
||||
'description' => ($desc !== "automations.type.{$type}.description") ? $desc : $def['description'],
|
||||
'icon' => $def['icon_app'],
|
||||
'color' => $def['color_hex'],
|
||||
'is_free' => $isFree,
|
||||
'is_active' => $userAuto?->active ?? false,
|
||||
'is_configured' => $userAuto !== null,
|
||||
'available' => $isFree || $isPro,
|
||||
'channels' => $def['channels'] ?? ['push'],
|
||||
'settings' => $userAuto?->config ?? $def['defaults'],
|
||||
'settings_schema' => collect($def['defaults'])
|
||||
->except(['channel', 'weekdays_only'])
|
||||
->toArray(),
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, string $type): JsonResponse
|
||||
{
|
||||
$types = config('automations.types');
|
||||
if (!isset($types[$type])) {
|
||||
return response()->json(['success' => false, 'message' => 'Unbekannter Typ.'], 422);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$def = $types[$type];
|
||||
$isPro = $user->isInternalUser() || $user->hasFeature('automations');
|
||||
|
||||
if (!$def['is_free'] && !$isPro) {
|
||||
return response()->json(['success' => false, 'message' => 'Pro erforderlich.'], 403);
|
||||
}
|
||||
|
||||
$config = array_merge($def['defaults'], $request->input('settings', []));
|
||||
$is_active = $request->boolean('is_active', true);
|
||||
|
||||
$user->automations()->updateOrCreate(
|
||||
['type' => $type],
|
||||
['name' => $def['name'], 'active' => $is_active, 'config' => $config]
|
||||
);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function toggle(Request $request, string $type): JsonResponse
|
||||
{
|
||||
$types = config('automations.types');
|
||||
if (!isset($types[$type])) {
|
||||
return response()->json(['success' => false, 'message' => 'Unbekannter Typ.'], 404);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$def = $types[$type];
|
||||
$isPro = $user->isInternalUser() || $user->hasFeature('automations');
|
||||
|
||||
if (!(bool) ($def['is_free'] ?? false) && !$isPro) {
|
||||
return response()->json(['success' => false, 'message' => 'Pro erforderlich.'], 403);
|
||||
}
|
||||
|
||||
$request->validate(['is_active' => 'boolean']);
|
||||
|
||||
$automation = $user->automations()->firstOrCreate(
|
||||
['type' => $type],
|
||||
['name' => $def['name'], 'active' => false, 'config' => $def['defaults']]
|
||||
);
|
||||
|
||||
$newActive = $request->has('is_active')
|
||||
? $request->boolean('is_active')
|
||||
: !$automation->active;
|
||||
|
||||
$automation->update(['active' => $newActive]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => ['type' => $type, 'is_active' => $automation->active],
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $type): JsonResponse
|
||||
{
|
||||
$request->user()->automations()->where('type', $type)->delete();
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Contact;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = $request->user()->contacts();
|
||||
|
||||
if ($request->has('search')) {
|
||||
$query->search($request->search);
|
||||
}
|
||||
|
||||
$contacts = $query->orderBy('name')->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $contacts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'nullable|string|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'type' => 'in:privat,arbeit,kunde,sonstiges',
|
||||
'notes' => 'nullable|string|max:2000',
|
||||
'birthday' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$contact = $request->user()->contacts()->create($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $contact,
|
||||
'message' => 'Kontakt erstellt.',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$contact = $request->user()->contacts()->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|string|max:255',
|
||||
'email' => 'nullable|string|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'type' => 'in:privat,arbeit,kunde,sonstiges',
|
||||
'notes' => 'nullable|string|max:2000',
|
||||
'birthday' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$contact->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $contact->fresh(),
|
||||
'message' => 'Kontakt aktualisiert.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$contact = $request->user()->contacts()->findOrFail($id);
|
||||
$contact->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => null,
|
||||
'message' => 'Kontakt gelöscht.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Contact;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function birthdays(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$tz = $user->timezone;
|
||||
$today = now($tz)->format('m-d');
|
||||
|
||||
$all = Contact::where('user_id', $user->id)
|
||||
->whereNotNull('birthday')
|
||||
->get();
|
||||
|
||||
$birthdaysToday = $all
|
||||
->filter(fn ($c) => Carbon::parse($c->birthday)->format('m-d') === $today)
|
||||
->values()
|
||||
->map(fn ($c) => [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'birthday' => $c->birthday,
|
||||
'age' => Carbon::parse($c->birthday)->year > 1900
|
||||
? now()->year - Carbon::parse($c->birthday)->year
|
||||
: null,
|
||||
]);
|
||||
|
||||
$birthdaysSoon = $all
|
||||
->filter(function ($c) use ($tz) {
|
||||
$days = now($tz)->diffInDays(
|
||||
Carbon::parse($c->birthday)->setYear(now($tz)->year),
|
||||
false
|
||||
);
|
||||
return $days > 0 && $days <= 7;
|
||||
})
|
||||
->sortBy(fn ($c) => Carbon::parse($c->birthday)->format('m-d'))
|
||||
->values()
|
||||
->map(fn ($c) => [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'birthday' => $c->birthday,
|
||||
'days_until' => (int) now($tz)->diffInDays(
|
||||
Carbon::parse($c->birthday)->setYear(now($tz)->year),
|
||||
false
|
||||
),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'today' => $birthdaysToday,
|
||||
'soon' => $birthdaysSoon,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Device;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DeviceController extends Controller
|
||||
{
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'device_id' => 'required|string',
|
||||
'push_token' => 'nullable|string',
|
||||
'platform' => 'required|in:ios,android',
|
||||
'device_name' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$device = Device::updateOrCreate(
|
||||
[
|
||||
'user_id' => auth()->id(),
|
||||
'device_id' => $request->device_id,
|
||||
],
|
||||
[
|
||||
'push_token' => $request->push_token,
|
||||
'platform' => $request->platform,
|
||||
'device_name' => $request->device_name,
|
||||
'active' => true,
|
||||
'last_seen' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
// Dasselbe Push-Token bei anderen Usern deaktivieren
|
||||
if ($request->push_token) {
|
||||
Device::where('push_token', $request->push_token)
|
||||
->where('user_id', '!=', auth()->id())
|
||||
->update(['push_token' => null, 'active' => false]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $device,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function updateToken(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'device_id' => 'required|string',
|
||||
'push_token' => 'required|string',
|
||||
]);
|
||||
|
||||
Device::where('user_id', auth()->id())
|
||||
->where('device_id', $request->device_id)
|
||||
->update([
|
||||
'push_token' => $request->push_token,
|
||||
'active' => true,
|
||||
'last_seen' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function deactivateToken(string $deviceId): JsonResponse
|
||||
{
|
||||
Device::where('user_id', auth()->id())
|
||||
->where('device_id', $deviceId)
|
||||
->update(['push_token' => null, 'active' => false]);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $deviceId): JsonResponse
|
||||
{
|
||||
Device::where('user_id', auth()->id())
|
||||
->where('device_id', $deviceId)
|
||||
->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Gerät entfernt.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EventController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'from' => 'required|date',
|
||||
'to' => 'required|date|after_or_equal:from',
|
||||
]);
|
||||
|
||||
$events = $request->user()->events()
|
||||
->with('contacts')
|
||||
->where(function ($q) use ($request) {
|
||||
$q->whereBetween('starts_at', [$request->from, $request->to])
|
||||
->orWhereBetween('ends_at', [$request->from, $request->to])
|
||||
->orWhere(function ($q2) use ($request) {
|
||||
$q2->where('starts_at', '<=', $request->from)
|
||||
->where('ends_at', '>=', $request->to);
|
||||
});
|
||||
})
|
||||
->orderBy('starts_at')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $events,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'starts_at' => 'required_without:start_date|date',
|
||||
'ends_at' => 'nullable|date|after_or_equal:starts_at',
|
||||
'start_date' => 'required_without:starts_at|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
'is_all_day' => 'boolean',
|
||||
'notes' => 'nullable|string',
|
||||
'color' => 'nullable|string|max:7',
|
||||
'reminders' => 'nullable|array',
|
||||
'reminders.*.type' => 'required|in:before,time_of_day,day_before',
|
||||
'reminders.*.minutes' => 'nullable|integer|min:1',
|
||||
'reminders.*.time' => 'nullable|date_format:H:i',
|
||||
'recurrence' => 'nullable|in:daily,weekly,monthly,yearly',
|
||||
'recurrence_end_date' => 'nullable|date',
|
||||
'attendee_ids' => 'nullable|array',
|
||||
'attendee_ids.*' => 'uuid|exists:contacts,id',
|
||||
]);
|
||||
|
||||
$event = $request->user()->events()->create($validated);
|
||||
$event->contacts()->sync($request->attendee_ids ?? []);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $event->load('contacts'),
|
||||
'message' => 'Termin erstellt.',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$event = $request->user()->events()->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'starts_at' => 'sometimes|date',
|
||||
'ends_at' => 'nullable|date|after_or_equal:starts_at',
|
||||
'start_date' => 'sometimes|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
'is_all_day' => 'boolean',
|
||||
'notes' => 'nullable|string',
|
||||
'color' => 'nullable|string|max:7',
|
||||
'reminders' => 'nullable|array',
|
||||
'reminders.*.type' => 'required|in:before,time_of_day,day_before',
|
||||
'reminders.*.minutes' => 'nullable|integer|min:1',
|
||||
'reminders.*.time' => 'nullable|date_format:H:i',
|
||||
'recurrence' => 'nullable|in:daily,weekly,monthly,yearly',
|
||||
'recurrence_end_date' => 'nullable|date',
|
||||
'attendee_ids' => 'nullable|array',
|
||||
'attendee_ids.*' => 'uuid|exists:contacts,id',
|
||||
]);
|
||||
|
||||
$event->update($validated);
|
||||
|
||||
if ($request->has('attendee_ids')) {
|
||||
$event->contacts()->sync($request->attendee_ids ?? []);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $event->fresh()->load('contacts'),
|
||||
'message' => 'Termin aktualisiert.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$event = $request->user()->events()->findOrFail($id);
|
||||
$event->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => null,
|
||||
'message' => 'Termin gelöscht.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Note;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NoteController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = $request->user()->notes();
|
||||
|
||||
if ($request->has('search')) {
|
||||
$query->search($request->search);
|
||||
}
|
||||
|
||||
$notes = $query->orderByDesc('pinned')
|
||||
->orderByDesc('updated_at')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'content' => 'nullable|string',
|
||||
'color' => 'in:yellow,blue,green,pink,purple,gray',
|
||||
'pinned' => 'boolean',
|
||||
]);
|
||||
|
||||
$note = $request->user()->notes()->create($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $note,
|
||||
'message' => 'Notiz erstellt.',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$note = $request->user()->notes()->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'content' => 'nullable|string',
|
||||
'color' => 'in:yellow,blue,green,pink,purple,gray',
|
||||
'pinned' => 'boolean',
|
||||
]);
|
||||
|
||||
$note->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $note->fresh(),
|
||||
'message' => 'Notiz aktualisiert.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$note = $request->user()->notes()->findOrFail($id);
|
||||
$note->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => null,
|
||||
'message' => 'Notiz gelöscht.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
public function notificationSettings(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($request->isMethod('put')) {
|
||||
$request->validate([
|
||||
'channels' => 'array',
|
||||
'channels.in_app' => 'boolean',
|
||||
'channels.push' => 'boolean',
|
||||
'channels.email' => 'boolean',
|
||||
]);
|
||||
|
||||
$channels = $request->input('channels', []);
|
||||
|
||||
$updates = [];
|
||||
if (isset($channels['in_app'])) $updates['notify_in_app'] = $channels['in_app'];
|
||||
if (isset($channels['push'])) $updates['notify_push'] = $channels['push'];
|
||||
if (isset($channels['email'])) $updates['notify_email'] = $channels['email'];
|
||||
|
||||
if ($updates) {
|
||||
$user->update($updates);
|
||||
}
|
||||
|
||||
// Devices aktivieren/deaktivieren je nach Push-Wunsch
|
||||
if (isset($channels['push'])) {
|
||||
$user->devices()->update(['active' => (bool) $channels['push']]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'channels' => [
|
||||
'in_app' => (bool) ($user->notify_in_app ?? true),
|
||||
'push' => (bool) ($user->notify_push ?? false),
|
||||
'email' => (bool) ($user->notify_email ?? false),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function credits(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Verbrauch aus AgentLogs (das ist die tatsächliche Nutzung).
|
||||
// Label-Priorität wie im Web: output.title > input > type.
|
||||
$usage = $user->agentLogs()
|
||||
->orderByDesc('created_at')
|
||||
->take(30)
|
||||
->get()
|
||||
->map(fn ($log) => [
|
||||
'type' => $log->type,
|
||||
'description' => $log->output['title'] ?? $log->input ?? $log->type,
|
||||
'amount' => -$log->credits,
|
||||
'duration_ms' => $log->duration_ms ?? 0,
|
||||
'created_at' => $log->created_at,
|
||||
]);
|
||||
|
||||
// Bonus-Credits aus CreditTransactions
|
||||
$bonuses = $user->creditTransactions()
|
||||
->orderByDesc('created_at')
|
||||
->take(10)
|
||||
->get()
|
||||
->map(fn ($tx) => [
|
||||
'type' => $tx->type,
|
||||
'description' => $tx->description,
|
||||
'amount' => $tx->amount,
|
||||
'created_at' => $tx->created_at,
|
||||
]);
|
||||
|
||||
// Zusammenführen und nach Datum sortieren
|
||||
$transactions = $usage->merge($bonuses)
|
||||
->sortByDesc('created_at')
|
||||
->values()
|
||||
->take(30);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'credits_used' => $user->effective_usage,
|
||||
'credit_limit' => $user->effective_limit,
|
||||
'bonus_credits' => $user->bonus_credits,
|
||||
'usage_percent' => $user->usage_percent,
|
||||
'transactions' => $transactions,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function affiliate(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$affiliate = $user->affiliate;
|
||||
|
||||
if (! $affiliate) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$referrals = $affiliate->referrals()
|
||||
->with('referredUser')
|
||||
->latest()
|
||||
->get()
|
||||
->map(fn ($r) => [
|
||||
'name' => $r->referredUser?->name ?? 'Unbekannt',
|
||||
'registered_at' => $r->registered_at,
|
||||
'qualifies_at' => $r->qualifies_at,
|
||||
'status' => $r->status,
|
||||
'credits_awarded' => $r->credits_awarded,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'code' => $affiliate->code,
|
||||
'referral_link' => $affiliate->referral_link,
|
||||
'referrals_count' => $affiliate->total_referrals,
|
||||
'qualified_count' => $affiliate->qualified_referrals,
|
||||
'credits_earned' => $affiliate->total_credits_earned,
|
||||
'status' => $affiliate->status,
|
||||
'referrals' => $referrals,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateProfile(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|string|max:255',
|
||||
'email' => 'sometimes|email|max:255',
|
||||
'locale' => 'sometimes|in:de,en',
|
||||
'timezone' => 'sometimes|string|max:100',
|
||||
]);
|
||||
|
||||
$request->user()->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Profil aktualisiert.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatePassword(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'current_password' => 'required|string',
|
||||
'password' => ['required', 'confirmed', Password::min(6)],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! Hash::check($request->current_password, $user->password)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Aktuelles Passwort ist falsch.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$user->update(['password' => Hash::make($request->password)]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Passwort geändert.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteAccount(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Alle Daten löschen
|
||||
$user->events()->delete();
|
||||
$user->tasks()->delete();
|
||||
$user->notes()->delete();
|
||||
$user->contacts()->delete();
|
||||
$user->agentLogs()->delete();
|
||||
$user->creditTransactions()->delete();
|
||||
$user->tokens()->delete();
|
||||
|
||||
if ($user->affiliate) {
|
||||
$user->affiliate->referrals()->delete();
|
||||
$user->affiliate->creditLogs()->delete();
|
||||
$user->affiliate->delete();
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Konto gelöscht.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Task;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TaskController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = $request->user()->tasks();
|
||||
|
||||
if ($request->status === 'all') {
|
||||
// Keine Filterung — alle Tasks zurückgeben
|
||||
} elseif ($request->has('status')) {
|
||||
$query->ofStatus($request->status);
|
||||
} else {
|
||||
$query->where('status', '!=', 'done');
|
||||
}
|
||||
|
||||
if ($request->has('search')) {
|
||||
$query->search($request->search);
|
||||
}
|
||||
|
||||
$tasks = $query->orderByRaw("FIELD(priority, 'high', 'medium', 'low')")
|
||||
->orderBy('due_at')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $tasks,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'priority' => 'in:low,medium,high',
|
||||
'due_at' => 'nullable|date',
|
||||
'reminder_at' => 'nullable|date',
|
||||
]);
|
||||
|
||||
if ($request->filled('reminder_at')) {
|
||||
$limitError = $this->checkReminderLimit($request->user());
|
||||
if ($limitError) return $limitError;
|
||||
}
|
||||
|
||||
$validated['status'] = 'pending';
|
||||
$validated['reminder_sent'] = false;
|
||||
|
||||
$task = $request->user()->tasks()->create($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $task,
|
||||
'message' => 'Aufgabe erstellt.',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$task = $request->user()->tasks()->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'status' => 'in:pending,in_progress,done',
|
||||
'priority' => 'in:low,medium,high',
|
||||
'due_at' => 'nullable|date',
|
||||
'reminder_at' => 'nullable|date',
|
||||
]);
|
||||
|
||||
if ($request->filled('reminder_at') && $request->reminder_at !== optional($task->reminder_at)->toDateTimeString()) {
|
||||
$limitError = $this->checkReminderLimit($request->user(), $task->id);
|
||||
if ($limitError) return $limitError;
|
||||
}
|
||||
|
||||
if (isset($validated['status']) && $validated['status'] === 'done' && ! $task->isDone()) {
|
||||
$validated['completed_at'] = now();
|
||||
}
|
||||
|
||||
if (array_key_exists('reminder_at', $validated)) {
|
||||
$validated['reminder_sent'] = false;
|
||||
}
|
||||
|
||||
$task->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $task->fresh(),
|
||||
'message' => 'Aufgabe aktualisiert.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function checkReminderLimit(\App\Models\User $user, ?string $excludeTaskId = null): ?\Illuminate\Http\JsonResponse
|
||||
{
|
||||
$isPro = $user->subscription?->plan?->plan_key !== 'free';
|
||||
if ($isPro) return null;
|
||||
|
||||
$query = Task::where('user_id', $user->id)
|
||||
->whereNotNull('reminder_at')
|
||||
->where('reminder_at', '>', now())
|
||||
->where('reminder_sent', false)
|
||||
->where('status', '!=', 'done');
|
||||
|
||||
if ($excludeTaskId) {
|
||||
$query->where('id', '!=', $excludeTaskId);
|
||||
}
|
||||
|
||||
$count = $query->count();
|
||||
|
||||
if ($count >= 3) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'limit_reached',
|
||||
'data' => ['limit' => 3, 'current' => $count],
|
||||
], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$task = $request->user()->tasks()->findOrFail($id);
|
||||
$task->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => null,
|
||||
'message' => 'Aufgabe gelöscht.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Translation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class TranslationController extends Controller
|
||||
{
|
||||
public function index(string $locale): JsonResponse
|
||||
{
|
||||
$translations = Translation::where('locale', $locale)
|
||||
->pluck('value', 'key');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $translations,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Integration;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CalendarIntegration;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GoogleCalendarController extends Controller
|
||||
{
|
||||
private const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
private const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
private const USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo';
|
||||
private const CALENDAR_URL = 'https://www.googleapis.com/calendar/v3/calendars/primary';
|
||||
|
||||
public function redirect()
|
||||
{
|
||||
$state = Str::random(40);
|
||||
session(['google_oauth_state' => $state]);
|
||||
|
||||
$query = http_build_query([
|
||||
'client_id' => config('services.google_calendar.client_id'),
|
||||
'redirect_uri' => config('services.google_calendar.redirect'),
|
||||
'response_type' => 'code',
|
||||
'scope' => 'https://www.googleapis.com/auth/calendar openid email profile',
|
||||
'access_type' => 'offline',
|
||||
'prompt' => 'consent',
|
||||
'state' => $state,
|
||||
]);
|
||||
|
||||
return redirect(self::AUTH_URL.'?'.$query);
|
||||
}
|
||||
|
||||
public function callback(Request $request)
|
||||
{
|
||||
// CSRF-State prüfen
|
||||
if ($request->state !== session('google_oauth_state')) {
|
||||
return redirect()->route('integrations.index')
|
||||
->with('integration_error', 'Ungültiger State-Parameter.');
|
||||
}
|
||||
|
||||
if ($request->has('error')) {
|
||||
return redirect()->route('integrations.index')
|
||||
->with('integration_error', 'Zugriff verweigert: '.$request->error);
|
||||
}
|
||||
|
||||
// Code gegen Token tauschen
|
||||
$response = Http::asForm()->post(self::TOKEN_URL, [
|
||||
'code' => $request->code,
|
||||
'client_id' => config('services.google_calendar.client_id'),
|
||||
'client_secret' => config('services.google_calendar.client_secret'),
|
||||
'redirect_uri' => config('services.google_calendar.redirect'),
|
||||
'grant_type' => 'authorization_code',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
return redirect()->route('integrations.index')
|
||||
->with('integration_error', 'Token-Austausch fehlgeschlagen.');
|
||||
}
|
||||
|
||||
$tokens = $response->json();
|
||||
|
||||
// E-Mail des Google-Accounts holen
|
||||
$userInfo = Http::withToken($tokens['access_token'])
|
||||
->get(self::USERINFO_URL)
|
||||
->json();
|
||||
|
||||
// Primären Kalender-Namen holen
|
||||
$calendarInfo = Http::withToken($tokens['access_token'])
|
||||
->get(self::CALENDAR_URL)
|
||||
->json();
|
||||
|
||||
CalendarIntegration::updateOrCreate(
|
||||
['user_id' => auth()->id(), 'provider' => 'google'],
|
||||
[
|
||||
'provider_email' => $userInfo['email'] ?? null,
|
||||
'calendar_id' => 'primary',
|
||||
'calendar_name' => $calendarInfo['summary'] ?? 'Primärer Kalender',
|
||||
'access_token' => $tokens['access_token'],
|
||||
'refresh_token' => $tokens['refresh_token'] ?? null,
|
||||
'token_expires_at' => now()->addSeconds($tokens['expires_in'] ?? 3600),
|
||||
]
|
||||
);
|
||||
|
||||
return redirect()->route('integrations.index')
|
||||
->with('integration_connected', 'google');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Integration;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CalendarIntegration;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OutlookCalendarController extends Controller
|
||||
{
|
||||
private function authUrl(): string
|
||||
{
|
||||
$tenant = config('services.microsoft.tenant', 'common');
|
||||
return "https://login.microsoftonline.com/{$tenant}/oauth2/v2.0/authorize";
|
||||
}
|
||||
|
||||
private function tokenUrl(): string
|
||||
{
|
||||
$tenant = config('services.microsoft.tenant', 'common');
|
||||
return "https://login.microsoftonline.com/{$tenant}/oauth2/v2.0/token";
|
||||
}
|
||||
|
||||
private const ME_URL = 'https://graph.microsoft.com/v1.0/me';
|
||||
private const CALENDAR_URL = 'https://graph.microsoft.com/v1.0/me/calendars?$top=1&$orderby=name';
|
||||
|
||||
public function redirect()
|
||||
{
|
||||
$state = Str::random(40);
|
||||
session(['outlook_oauth_state' => $state]);
|
||||
|
||||
$query = http_build_query([
|
||||
'client_id' => config('services.microsoft.client_id'),
|
||||
'redirect_uri' => config('services.microsoft.redirect'),
|
||||
'response_type' => 'code',
|
||||
'scope' => 'openid email profile Calendars.ReadWrite offline_access',
|
||||
'state' => $state,
|
||||
]);
|
||||
|
||||
return redirect($this->authUrl().'?'.$query);
|
||||
}
|
||||
|
||||
public function callback(Request $request)
|
||||
{
|
||||
if ($request->state !== session('outlook_oauth_state')) {
|
||||
return redirect()->route('integrations.index')
|
||||
->with('integration_error', 'Ungültiger State-Parameter.');
|
||||
}
|
||||
|
||||
if ($request->has('error')) {
|
||||
return redirect()->route('integrations.index')
|
||||
->with('integration_error', 'Zugriff verweigert: '.$request->error_description);
|
||||
}
|
||||
|
||||
$response = Http::asForm()->post($this->tokenUrl(), [
|
||||
'code' => $request->code,
|
||||
'client_id' => config('services.microsoft.client_id'),
|
||||
'client_secret' => config('services.microsoft.client_secret'),
|
||||
'redirect_uri' => config('services.microsoft.redirect'),
|
||||
'grant_type' => 'authorization_code',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
return redirect()->route('integrations.index')
|
||||
->with('integration_error', 'Token-Austausch fehlgeschlagen.');
|
||||
}
|
||||
|
||||
$tokens = $response->json();
|
||||
|
||||
$me = Http::withToken($tokens['access_token'])
|
||||
->get(self::ME_URL)
|
||||
->json();
|
||||
|
||||
$calendars = Http::withToken($tokens['access_token'])
|
||||
->get(self::CALENDAR_URL)
|
||||
->json();
|
||||
|
||||
$primaryCalendar = $calendars['value'][0] ?? null;
|
||||
|
||||
CalendarIntegration::updateOrCreate(
|
||||
['user_id' => auth()->id(), 'provider' => 'outlook'],
|
||||
[
|
||||
'provider_email' => $me['mail'] ?? $me['userPrincipalName'] ?? null,
|
||||
'calendar_id' => $primaryCalendar['id'] ?? null,
|
||||
'calendar_name' => $primaryCalendar['name'] ?? 'Primärer Kalender',
|
||||
'access_token' => $tokens['access_token'],
|
||||
'refresh_token' => $tokens['refresh_token'] ?? null,
|
||||
'token_expires_at' => now()->addSeconds($tokens['expires_in'] ?? 3600),
|
||||
]
|
||||
);
|
||||
|
||||
return redirect()->route('integrations.index')
|
||||
->with('integration_connected', 'outlook');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Stripe;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\StripeService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Stripe\Exception\SignatureVerificationException;
|
||||
|
||||
class WebhookController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, StripeService $stripe): Response
|
||||
{
|
||||
$payload = $request->getContent();
|
||||
$signature = $request->header('Stripe-Signature', '');
|
||||
|
||||
try {
|
||||
$stripe->handleWebhook($payload, $signature);
|
||||
} catch (SignatureVerificationException $e) {
|
||||
return response('Invalid signature', 400);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
return response('Webhook error: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Verification;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class VerifyController extends Controller
|
||||
{
|
||||
public function verify(Verification $verification)
|
||||
{
|
||||
$user = $verification->user;
|
||||
|
||||
// 🔥 schon verifiziert
|
||||
if ($user->email_verified_at) {
|
||||
return redirect()->route('dashboard.index');
|
||||
}
|
||||
|
||||
// 🔥 abgelaufen
|
||||
if ($verification->isExpired()) {
|
||||
return redirect()->route('verify.notice', [
|
||||
'user' => $user->id
|
||||
])->with('error', t('auth.verify.expired'));
|
||||
}
|
||||
|
||||
// 🔥 schon benutzt
|
||||
if ($verification->isVerified()) {
|
||||
return redirect()->route('dashboard.index');
|
||||
}
|
||||
|
||||
// 🔥 USER UPDATEN
|
||||
if ($verification->type === 'email') {
|
||||
$user->update([
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
Verification::where('user_id', $user->id)
|
||||
->where('type', $verification->type)
|
||||
->delete();
|
||||
|
||||
return redirect()->route('dashboard.index')
|
||||
->with('success', t('auth.verify.success'));
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
//namespace App\Http\Controllers;
|
||||
//
|
||||
//use App\Models\User;
|
||||
//use Illuminate\Http\Request;
|
||||
//use Illuminate\Support\Facades\Auth;
|
||||
//
|
||||
//class VerifyController extends Controller
|
||||
//{
|
||||
// public function verify(User $user)
|
||||
// {
|
||||
// if ($user->email_verified_at) {
|
||||
// return redirect()->route('dashboard.index');
|
||||
// }
|
||||
//
|
||||
// $user->update([
|
||||
// 'email_verified_at' => now(),
|
||||
// 'email_verification_code' => null,
|
||||
// 'email_verification_expires_at' => null,
|
||||
// 'verification_resends' => 0,
|
||||
// 'verification_resends_reset_at' => null,
|
||||
// ]);
|
||||
//
|
||||
// Auth::login($user);
|
||||
//
|
||||
// return redirect()->route('dashboard.index')
|
||||
// ->with('success', t('auth.verify.success'));
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Webhooks;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\SyncFromGoogleCalendarJob;
|
||||
use App\Models\CalendarIntegration;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GoogleCalendarWebhookController extends Controller
|
||||
{
|
||||
/**
|
||||
* Google sendet hier einen POST sobald sich im Kalender etwas ändert.
|
||||
* Header X-Goog-Channel-Id identifiziert welche Integration betroffen ist.
|
||||
* Header X-Goog-Resource-State: "sync" (Test-Ping) | "exists" (Änderung)
|
||||
*/
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$channelId = $request->header('X-Goog-Channel-Id');
|
||||
$resourceState = $request->header('X-Goog-Resource-State');
|
||||
|
||||
// Erster Ping nach Watch-Registrierung — nur bestätigen
|
||||
if ($resourceState === 'sync') {
|
||||
Log::info('Google Calendar Webhook: Sync-Ping empfangen', ['channel_id' => $channelId]);
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
if (!$channelId || $resourceState !== 'exists') {
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
$integration = CalendarIntegration::where('watch_channel_id', $channelId)->first();
|
||||
|
||||
if (!$integration) {
|
||||
Log::warning('Google Calendar Webhook: Unbekannter Channel', [
|
||||
'channel_id' => $channelId,
|
||||
]);
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
Log::info('Google Calendar Webhook: Änderung empfangen → Sync starten', [
|
||||
'user_id' => $integration->user_id,
|
||||
'channel_id' => $channelId,
|
||||
]);
|
||||
|
||||
// Pull-Sync als Job dispatchen (nicht blockierend)
|
||||
SyncFromGoogleCalendarJob::dispatch($integration->id);
|
||||
|
||||
return response('', 200);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CheckAppVersion
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$currentVersion = config('app.version');
|
||||
|
||||
if (session('app_version') !== $currentVersion) {
|
||||
session(['show_update_banner' => true]);
|
||||
session(['app_version' => $currentVersion]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAuthenticated
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 🔥 Session prüfen
|
||||
if (Auth::check()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// 🔥 Optional: API Token Support (für später)
|
||||
if ($token = $request->bearerToken()) {
|
||||
|
||||
$user = \App\Models\User::whereHas('tokens', function ($q) use ($token) {
|
||||
$q->where('token', hash('sha256', $token));
|
||||
})->first();
|
||||
|
||||
if ($user) {
|
||||
Auth::login($user);
|
||||
$user->tokens()->where('token', hash('sha256', $token))->update(['last_used_at' => now()]);
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => 'Unauthenticated',
|
||||
], 401);
|
||||
}
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureIsAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!auth()->check() || !auth()->user()->isAdmin()) {
|
||||
abort(403, 'Kein Zugriff.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserIsVerified
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ForceJsonResponse
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$request->headers->set('Accept', 'application/json');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RedirectIfAuthenticatedCustom
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Auth::check()) {
|
||||
return redirect()->route('dashboard.index');
|
||||
}
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RoleMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next, string ...$roles): Response
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
// Super Admin hat immer Zugang
|
||||
if ($user->isSuperAdmin()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Prüfe ob User mindestens eine der erlaubten Rollen hat (mit Hierarchie)
|
||||
if (!empty($roles)) {
|
||||
$hasAccess = collect($roles)->some(
|
||||
fn(string $role) => $user->hasAtLeastRole($role)
|
||||
);
|
||||
|
||||
if (!$hasAccess) {
|
||||
abort(403, 'Keine Berechtigung.');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$available = array_keys(config('app.locales'));
|
||||
$locale = config('app.default_locale', 'de');
|
||||
|
||||
if (auth()->check()) {
|
||||
$userLocale = auth()->user()->locale;
|
||||
|
||||
if (in_array($userLocale, $available)) {
|
||||
$locale = $userLocale;
|
||||
// Session-Backup damit AJAX-Requests auch ohne frischen DB-Fetch korrekt sind
|
||||
session(['locale' => $locale]);
|
||||
} elseif (session()->has('locale') && in_array(session('locale'), $available)) {
|
||||
$locale = session('locale');
|
||||
}
|
||||
} elseif (session()->has('locale') && in_array(session('locale'), $available)) {
|
||||
$locale = session('locale');
|
||||
} else {
|
||||
$browser = $request->getPreferredLanguage($available);
|
||||
if ($browser) {
|
||||
$locale = $browser;
|
||||
}
|
||||
}
|
||||
|
||||
app()->setLocale($locale);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TrackAffiliateCode
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
if ($request->has('ref')) {
|
||||
session(['affiliate_ref_code' => $request->get('ref')]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Illuminate\Http\Middleware\TrustProxies as Middleware;
|
||||
|
||||
class TrustProxies extends Middleware
|
||||
{
|
||||
protected $proxies = '*';
|
||||
|
||||
protected $headers =
|
||||
Request::HEADER_X_FORWARDED_FOR |
|
||||
Request::HEADER_X_FORWARDED_HOST |
|
||||
Request::HEADER_X_FORWARDED_PORT |
|
||||
Request::HEADER_X_FORWARDED_PROTO;
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Notification;
|
||||
use App\Models\User;
|
||||
use App\Services\MailerService;
|
||||
use App\Services\PushService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SendEventReminder implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Event $event,
|
||||
public array $reminder,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$user = $this->event->user;
|
||||
|
||||
if (!$user) {
|
||||
return;
|
||||
}
|
||||
|
||||
\Log::info('SendEventReminder: starting', [
|
||||
'event' => $this->event->title,
|
||||
'user' => $user->name,
|
||||
'reminder' => $this->reminder,
|
||||
]);
|
||||
|
||||
$settings = $user->notification_settings ?? [];
|
||||
$emailEnabled = $settings['email_enabled'] ?? false;
|
||||
$isPro = $user->subscription?->isActive() ?? false;
|
||||
$isOwnReminder = !empty($this->event->reminders ?? []);
|
||||
|
||||
$minutes = $this->reminder['minutes'] ?? 30;
|
||||
$tz = $user->timezone ?? 'Europe/Vienna';
|
||||
$start = $this->event->starts_at->setTimezone($tz);
|
||||
|
||||
$body = match (true) {
|
||||
$minutes >= 1440 => 'Morgen um ' . $start->format('H:i') . ' Uhr',
|
||||
$minutes >= 60 => 'In ' . intval($minutes / 60) . ' Stunde(n)',
|
||||
default => 'In ' . $minutes . ' Minuten',
|
||||
};
|
||||
|
||||
// PUSH — wenn Token vorhanden (kein Pro/push_enabled Gate)
|
||||
$hasPushToken = $user->devices()
|
||||
->where('active', true)
|
||||
->whereNotNull('push_token')
|
||||
->exists();
|
||||
|
||||
\Log::info('SendEventReminder: user check', [
|
||||
'plan_key' => $user->subscription?->plan?->plan_key,
|
||||
'hasPushToken' => $hasPushToken,
|
||||
'isOwnReminder'=> $isOwnReminder,
|
||||
]);
|
||||
|
||||
if ($hasPushToken) {
|
||||
PushService::send(
|
||||
$user,
|
||||
'Erinnerung: ' . $this->event->title,
|
||||
$body,
|
||||
['type' => 'event_reminder', 'event_id' => $this->event->id]
|
||||
);
|
||||
|
||||
\Log::info('Event push sent', [
|
||||
'event' => $this->event->title,
|
||||
'user' => $user->name,
|
||||
]);
|
||||
} else {
|
||||
\Log::warning('SendEventReminder: push skipped', ['reason' => 'no_active_token']);
|
||||
}
|
||||
|
||||
// EMAIL
|
||||
if ($emailEnabled && ($isOwnReminder || $isPro)) {
|
||||
try {
|
||||
Mail::mailer(MailerService::forAutomation('event_reminder'))
|
||||
->to($user->email)
|
||||
->send(new \App\Mail\EventReminderMail($this->event, $this->reminder));
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning('EventReminder: Email failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// IN-APP — immer
|
||||
$this->sendInApp($user);
|
||||
}
|
||||
|
||||
private function sendInApp(User $user): void
|
||||
{
|
||||
$tz = $user->timezone ?? 'Europe/Vienna';
|
||||
$start = $this->event->starts_at->setTimezone($tz);
|
||||
$minutes = $this->reminder['minutes'] ?? 30;
|
||||
|
||||
$message = match (true) {
|
||||
$minutes >= 1440 => 'Morgen um ' . $start->format('H:i') . ' Uhr',
|
||||
$minutes >= 60 => 'In ' . ($minutes / 60) . ' Stunde(n)',
|
||||
default => 'In ' . $minutes . ' Minuten',
|
||||
};
|
||||
|
||||
Notification::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'event_reminder',
|
||||
'title' => $this->event->title,
|
||||
'message' => $message,
|
||||
'data' => ['event_id' => $this->event->id, 'reminder' => $this->reminder],
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildMessage(): string
|
||||
{
|
||||
$tz = $this->event->user->timezone ?? 'Europe/Vienna';
|
||||
$start = $this->event->starts_at->setTimezone($tz);
|
||||
|
||||
return match ($this->reminder['type']) {
|
||||
'before' => match (true) {
|
||||
($this->reminder['minutes'] ?? 0) >= 1440 =>
|
||||
'Morgen: ' . $this->event->title . ' um ' . $start->format('H:i') . ' Uhr',
|
||||
($this->reminder['minutes'] ?? 0) >= 60 =>
|
||||
$this->event->title . ' in ' . (($this->reminder['minutes'] ?? 60) / 60) . ' Stunde(n)',
|
||||
default =>
|
||||
$this->event->title . ' in ' . ($this->reminder['minutes'] ?? 30) . ' Minuten',
|
||||
},
|
||||
'time_of_day', 'day_before' =>
|
||||
'Heute: ' . $this->event->title . ' um ' . $start->format('H:i') . ' Uhr',
|
||||
default => $this->event->title,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\CalendarIntegration;
|
||||
use App\Services\GoogleCalendarService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Zieht neue und geänderte Events von Google Calendar und importiert sie lokal.
|
||||
* Wird per Schedule alle 15 Minuten ausgelöst (für alle User mit sync_mode read|both).
|
||||
*/
|
||||
class SyncFromGoogleCalendarJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $timeout = 60;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $integrationId
|
||||
) {}
|
||||
|
||||
public function handle(GoogleCalendarService $service): void
|
||||
{
|
||||
$integration = CalendarIntegration::find($this->integrationId);
|
||||
|
||||
if (!$integration) {
|
||||
Log::warning('SyncFromGoogleCalendarJob: Integration nicht gefunden', [
|
||||
'id' => $this->integrationId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array($integration->sync_mode, ['read', 'both'])) {
|
||||
return; // Kein Pull für diesen Modus
|
||||
}
|
||||
|
||||
Log::info('Google Calendar: Starte Pull-Sync', [
|
||||
'user_id' => $integration->user_id,
|
||||
'sync_mode' => $integration->sync_mode,
|
||||
'has_token' => (bool) $integration->sync_token,
|
||||
]);
|
||||
|
||||
$service->pullEvents($integration);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\Affiliate;
|
||||
use App\Models\AffiliateReferral;
|
||||
use App\Models\CreditTransaction;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
|
||||
class GiveBonusCredits
|
||||
{
|
||||
public function handle(Registered $event): void
|
||||
{
|
||||
$user = $event->user;
|
||||
$role = $user->role?->value ?? 'user';
|
||||
|
||||
// Keine Onboarding-Credits für interne Rollen
|
||||
$skipCredits = in_array($role, [
|
||||
'admin',
|
||||
'super_admin',
|
||||
'developer',
|
||||
'support',
|
||||
]);
|
||||
|
||||
if (!$skipCredits) {
|
||||
CreditTransaction::onboarding($user, 300);
|
||||
}
|
||||
|
||||
// Kein Affiliate-Account für interne Rollen
|
||||
$skipAffiliate = in_array($role, [
|
||||
'admin',
|
||||
'super_admin',
|
||||
'developer',
|
||||
'support',
|
||||
]);
|
||||
|
||||
if (!$skipAffiliate) {
|
||||
Affiliate::create([
|
||||
'user_id' => $user->id,
|
||||
'code' => Affiliate::generateCode($user),
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
// Referral tracken — nur für nicht-interne User
|
||||
if (!$skipCredits) {
|
||||
$refCode = session()->pull('affiliate_ref_code');
|
||||
if ($refCode) {
|
||||
$affiliate = Affiliate::where('code', $refCode)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
if ($affiliate && $affiliate->user_id !== $user->id) {
|
||||
AffiliateReferral::create([
|
||||
'affiliate_id' => $affiliate->id,
|
||||
'referred_user_id' => $user->id,
|
||||
'affiliate_code' => $refCode,
|
||||
'status' => 'pending',
|
||||
'registered_at' => now(),
|
||||
'qualifies_at' => now()->addMonths(3),
|
||||
]);
|
||||
|
||||
$affiliate->increment('total_referrals');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Activities;
|
||||
|
||||
use App\Models\Activity;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
public string $filterType = ''; // '' | event | reminder | automation | contact | integration | system
|
||||
|
||||
// Bei Filteränderung zurück auf Seite 1
|
||||
public function updatedSearch(): void { $this->resetPage(); }
|
||||
public function updatedFilterType(): void { $this->resetPage(); }
|
||||
|
||||
// ── Löschen ───────────────────────────────────────────────────────────
|
||||
|
||||
public function delete(string $id): void
|
||||
{
|
||||
Activity::where('user_id', auth()->id())->where('id', $id)->delete();
|
||||
}
|
||||
|
||||
public function clearAll(): void
|
||||
{
|
||||
Activity::where('user_id', auth()->id())->delete();
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
|
||||
public function render()
|
||||
{
|
||||
$userId = auth()->id();
|
||||
$tz = auth()->user()->timezone ?? 'UTC';
|
||||
|
||||
$query = Activity::forUser($userId)->latest();
|
||||
|
||||
if ($this->search !== '') {
|
||||
$query->search($this->search);
|
||||
}
|
||||
|
||||
if ($this->filterType !== '') {
|
||||
$query->ofType($this->filterType);
|
||||
}
|
||||
|
||||
$activities = $query->paginate(20);
|
||||
|
||||
// Datum-Gruppen für die Timeline (nach User-TZ)
|
||||
$grouped = $activities->getCollection()
|
||||
->groupBy(fn($a) => $a->created_at->setTimezone($tz)->toDateString());
|
||||
|
||||
// Statistiken (nur für ungefilterte Ansicht sinnvoll)
|
||||
$stats = Activity::forUser($userId)
|
||||
->selectRaw("
|
||||
count(*) as total,
|
||||
sum(case when DATE(created_at) = CURRENT_DATE then 1 else 0 end) as today,
|
||||
sum(case when type like 'event%' then 1 else 0 end) as events,
|
||||
sum(case when type like 'automation%' then 1 else 0 end) as automations
|
||||
")
|
||||
->first();
|
||||
|
||||
return view('livewire.activities.index', [
|
||||
'activities' => $activities,
|
||||
'grouped' => $grouped,
|
||||
'stats' => $stats,
|
||||
'tz' => $tz,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Affiliates;
|
||||
|
||||
use App\Models\Affiliate;
|
||||
use App\Models\AffiliateCreditLog;
|
||||
use App\Models\AffiliateReferral;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public function updatingSearch()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function toggleStatus(string $affiliateId)
|
||||
{
|
||||
$affiliate = Affiliate::findOrFail($affiliateId);
|
||||
$affiliate->update([
|
||||
'status' => $affiliate->status === 'active' ? 'paused' : 'active',
|
||||
]);
|
||||
|
||||
$this->dispatch('notify', type: 'success', message: 'Affiliate-Status aktualisiert.');
|
||||
}
|
||||
|
||||
public function banAffiliate(string $affiliateId)
|
||||
{
|
||||
Affiliate::findOrFail($affiliateId)->update(['status' => 'banned']);
|
||||
$this->dispatch('notify', type: 'success', message: 'Affiliate gesperrt.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$affiliates = Affiliate::with('user')
|
||||
->whereHas('user', fn($q) =>
|
||||
$q->whereNotIn('role', ['admin', 'super_admin', 'developer', 'support'])
|
||||
)
|
||||
->withCount([
|
||||
'referrals',
|
||||
'referrals as pending_count' => fn($q) => $q->where('status', 'pending'),
|
||||
'referrals as paid_count' => fn($q) => $q->where('status', 'paid'),
|
||||
])
|
||||
->when($this->search, fn($q) =>
|
||||
$q->whereHas('user', fn($uq) =>
|
||||
$uq->where('name', 'like', '%' . $this->search . '%')
|
||||
->orWhere('email', 'like', '%' . $this->search . '%')
|
||||
)->orWhere('code', 'like', '%' . $this->search . '%')
|
||||
)
|
||||
->orderByDesc('total_credits_earned')
|
||||
->paginate(25);
|
||||
|
||||
$stats = [
|
||||
'total_affiliates' => Affiliate::count(),
|
||||
'active_affiliates' => Affiliate::where('status', 'active')->count(),
|
||||
'total_referrals' => AffiliateReferral::count(),
|
||||
'pending_referrals' => AffiliateReferral::where('status', 'pending')->count(),
|
||||
'credits_paid_out' => AffiliateCreditLog::sum('credits'),
|
||||
];
|
||||
|
||||
return view('livewire.admin.affiliates.index', compact('affiliates', 'stats'))
|
||||
->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Enums\UserStatus;
|
||||
use App\Models\AgentLog;
|
||||
use App\Models\User;
|
||||
use Livewire\Component;
|
||||
|
||||
class Dashboard extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.dashboard', [
|
||||
'totalUsers' => User::count(),
|
||||
'activeUsers' => User::where('status', UserStatus::Active)->count(),
|
||||
'newThisMonth' => User::where('created_at', '>=', now()->startOfMonth())->count(),
|
||||
'proUsers' => User::whereHas('subscription', fn($q) => $q->where('status', 'active'))->count(),
|
||||
'totalCreditsUsed' => AgentLog::where('created_at', '>=', now()->startOfMonth())->sum('credits'),
|
||||
'totalCost' => AgentLog::where('created_at', '>=', now()->startOfMonth())->sum('cost_usd'),
|
||||
'recentLogs' => AgentLog::with('user')->latest()->limit(10)->get(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Features\Modals;
|
||||
|
||||
use App\Models\Feature;
|
||||
use App\Models\FeatureGroup;
|
||||
use Illuminate\Support\Str;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class Form extends ModalComponent
|
||||
{
|
||||
public $featureId;
|
||||
|
||||
public $label = '';
|
||||
public $key = '';
|
||||
public $icon = 'heroicon-o-check';
|
||||
|
||||
public $iconGroups = [
|
||||
'Allgemein' => [
|
||||
'heroicon-o-check',
|
||||
'heroicon-o-x-mark',
|
||||
'heroicon-o-star',
|
||||
'heroicon-o-bolt',
|
||||
],
|
||||
'User' => [
|
||||
'heroicon-o-user',
|
||||
'heroicon-o-users',
|
||||
],
|
||||
'Zeit' => [
|
||||
'heroicon-o-calendar',
|
||||
'heroicon-o-clock',
|
||||
],
|
||||
'System' => [
|
||||
'heroicon-o-cog',
|
||||
'heroicon-o-wrench',
|
||||
],
|
||||
];
|
||||
|
||||
public $group_id = null;
|
||||
public $active = true;
|
||||
|
||||
public $groups = [];
|
||||
|
||||
public function mount($id = null)
|
||||
{
|
||||
$this->groups = FeatureGroup::orderBy('sort')->get();
|
||||
|
||||
if ($id) {
|
||||
$feature = Feature::findOrFail($id);
|
||||
|
||||
$this->featureId = $feature->id;
|
||||
$this->label = $feature->label;
|
||||
$this->key = $feature->key;
|
||||
$this->icon = $feature->icon;
|
||||
$this->group_id = $feature->group_id;
|
||||
$this->active = $feature->active;
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedLabel($value)
|
||||
{
|
||||
if (!$this->featureId) {
|
||||
$this->key = Str::slug($value);
|
||||
}
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->validate([
|
||||
'label' => 'required|string|max:255',
|
||||
'key' => 'required|string|max:255',
|
||||
'group_id' => 'nullable|exists:feature_groups,id',
|
||||
]);
|
||||
|
||||
$feature = Feature::updateOrCreate(
|
||||
['id' => $this->featureId],
|
||||
[
|
||||
'label' => $this->label,
|
||||
'key' => $this->key,
|
||||
'icon' => $this->icon,
|
||||
'feature_group_id' => $this->group_id,
|
||||
'active' => $this->active,
|
||||
]
|
||||
);
|
||||
|
||||
// 👉 wichtig für Plan Modal reload
|
||||
$this->dispatch('featureCreated', featureId: $feature->id);
|
||||
$this->dispatch('closeModal');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.features.modals.form');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Plans;
|
||||
|
||||
use App\Models\Plan;
|
||||
use Livewire\Component;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
public $plans;
|
||||
|
||||
protected $listeners = ['planUpdated' => 'loadPlans'];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->loadPlans();
|
||||
}
|
||||
|
||||
public function loadPlans()
|
||||
{
|
||||
$this->plans = Plan::orderBy('price')->get();
|
||||
}
|
||||
|
||||
public function delete($id)
|
||||
{
|
||||
Plan::find($id)?->delete();
|
||||
$this->loadPlans();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.plans.index')
|
||||
->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Plans\Modals;
|
||||
|
||||
use App\Models\Plan;
|
||||
use App\Models\FeatureGroup;
|
||||
use Illuminate\Support\Str;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class Form extends ModalComponent
|
||||
{
|
||||
public $planId;
|
||||
public $ai_config;
|
||||
|
||||
public $name = '';
|
||||
public $price = 0;
|
||||
|
||||
public $credit_limit = 0;
|
||||
public $device_limit = 1;
|
||||
|
||||
public $yearly_discount_months = 0;
|
||||
|
||||
public $active = true;
|
||||
public $is_featured = false;
|
||||
|
||||
public $step = 1;
|
||||
|
||||
// 🔥 GROUPED FEATURES
|
||||
public $groupedFeatures = [];
|
||||
|
||||
// 🔥 SELECTED IDS
|
||||
public $selectedFeatures = [];
|
||||
|
||||
protected $listeners = [
|
||||
'featureCreated' => 'reloadFeatures',
|
||||
];
|
||||
|
||||
public function mount($id = null)
|
||||
{
|
||||
// 🔥 GROUPS + FEATURES LADEN
|
||||
$this->groupedFeatures = FeatureGroup::with([
|
||||
'features' => fn ($q) => $q->where('active', true)->orderBy('sort')
|
||||
])
|
||||
->orderBy('sort')
|
||||
->get();
|
||||
|
||||
if ($id) {
|
||||
$plan = Plan::with('features')->findOrFail($id);
|
||||
|
||||
$this->planId = $plan->id;
|
||||
$this->ai_config = $plan->ai_config ?? [];
|
||||
$this->name = $plan->name;
|
||||
$this->price = $plan->price;
|
||||
|
||||
$this->credit_limit = $plan->credit_limit;
|
||||
$this->device_limit = $plan->device_limit;
|
||||
|
||||
$this->yearly_discount_months = $plan->yearly_discount_months ?? 0;
|
||||
|
||||
// 🔥 WICHTIG: KEIN ? mehr
|
||||
$this->selectedFeatures = $plan->features
|
||||
? $plan->features->pluck('id')->toArray()
|
||||
: [];
|
||||
|
||||
$this->active = $plan->active;
|
||||
$this->is_featured = $plan->is_featured;
|
||||
}
|
||||
}
|
||||
|
||||
public function reloadFeatures($featureId = null)
|
||||
{
|
||||
$this->groupedFeatures = \App\Models\FeatureGroup::with([
|
||||
'features' => fn ($q) => $q->where('active', true)->orderBy('sort')
|
||||
])
|
||||
->orderBy('sort')
|
||||
->get();
|
||||
|
||||
// 👉 optional direkt auswählen
|
||||
if ($featureId) {
|
||||
$this->selectedFeatures[] = $featureId;
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleFeature($id)
|
||||
{
|
||||
if (in_array($id, $this->selectedFeatures)) {
|
||||
$this->selectedFeatures = array_values(
|
||||
array_diff($this->selectedFeatures, [$id])
|
||||
);
|
||||
} else {
|
||||
$this->selectedFeatures[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
public function nextStep()
|
||||
{
|
||||
$this->validateStep1();
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function prevStep()
|
||||
{
|
||||
$this->step = 1;
|
||||
}
|
||||
|
||||
public function updated($field)
|
||||
{
|
||||
if (in_array($field, ['price', 'credit_limit', 'device_limit', 'yearly_discount_months'])) {
|
||||
if ($this->$field < 0) {
|
||||
$this->$field = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ($field === 'yearly_discount_months' && $this->yearly_discount_months > 12) {
|
||||
$this->yearly_discount_months = 12;
|
||||
}
|
||||
|
||||
if ($field === 'price' && $this->price == 0) {
|
||||
$this->yearly_discount_months = 0;
|
||||
}
|
||||
}
|
||||
|
||||
protected function validateStep1()
|
||||
{
|
||||
$this->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'price' => 'required|integer|min:0',
|
||||
'credit_limit' => 'required|integer|min:0',
|
||||
'device_limit' => 'required|integer|min:0',
|
||||
'yearly_discount_months' => 'nullable|integer|min:0|max:12',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function getAiModelDefaults(): array
|
||||
{
|
||||
$model = $this->ai_config['model'] ?? null;
|
||||
|
||||
if (!$model) return [];
|
||||
|
||||
return config('ai_models.' . $model) ?? [];
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
// 🔥 AI CONFIG sauber aufbauen (Defaults + Overrides)
|
||||
$modelKey = $this->ai_config['model'] ?? null;
|
||||
|
||||
$finalAiConfig = [];
|
||||
|
||||
if ($modelKey) {
|
||||
$defaults = config('ai_models.' . $modelKey) ?? [];
|
||||
|
||||
// 👉 nur relevante Felder übernehmen
|
||||
$finalAiConfig = [
|
||||
'model_key' => $modelKey,
|
||||
'model' => $defaults['model'] ?? null,
|
||||
'max_tokens' => $this->ai_config['max_tokens'] ?? $defaults['max_tokens'] ?? null,
|
||||
'temperature' => $this->ai_config['temperature'] ?? $defaults['temperature'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$this->validateStep1();
|
||||
|
||||
$plan = Plan::updateOrCreate(
|
||||
['id' => $this->planId],
|
||||
[
|
||||
'name' => $this->name,
|
||||
'plan_key' => Str::slug($this->name),
|
||||
'price' => $this->price,
|
||||
'credit_limit' => $this->credit_limit,
|
||||
'device_limit' => $this->device_limit,
|
||||
'yearly_discount_months' => $this->price > 0 ? $this->yearly_discount_months : 0,
|
||||
'active' => $this->active,
|
||||
'is_featured' => $this->is_featured,
|
||||
'ai_config' => $finalAiConfig,
|
||||
]
|
||||
);
|
||||
|
||||
// 🔥 PIVOT SYNC
|
||||
$plan->features()->sync($this->selectedFeatures);
|
||||
|
||||
$this->dispatch('closeModal');
|
||||
$this->dispatch('planUpdated');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.plans.modals.form');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Translations;
|
||||
|
||||
use App\Models\Translation;
|
||||
use Livewire\Component;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
public $locale = 'de';
|
||||
public $translations = [];
|
||||
|
||||
public $search = '';
|
||||
public $onlyMissing = false;
|
||||
public $grouped = [];
|
||||
|
||||
protected $listeners = ['translations:updated' => 'loadTranslations'];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->loadTranslations();
|
||||
}
|
||||
|
||||
public function updatedLocale()
|
||||
{
|
||||
$this->loadTranslations();
|
||||
}
|
||||
|
||||
public function updatedSearch()
|
||||
{
|
||||
$this->loadTranslations();
|
||||
}
|
||||
|
||||
public function updatedOnlyMissing()
|
||||
{
|
||||
$this->loadTranslations();
|
||||
}
|
||||
|
||||
public function loadTranslations()
|
||||
{
|
||||
$query = Translation::query()->select('key')->distinct();
|
||||
|
||||
// 🔍 SEARCH
|
||||
if ($this->search) {
|
||||
$query->where('key', 'like', '%' . $this->search . '%');
|
||||
}
|
||||
|
||||
$keys = $query->pluck('key');
|
||||
|
||||
$groups = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
|
||||
$values = Translation::where('key', $key)
|
||||
->pluck('value', 'locale')
|
||||
->toArray();
|
||||
|
||||
// 🔥 FILTER: NUR FEHLENDE
|
||||
if ($this->onlyMissing) {
|
||||
if (count($values) === count(config('app.locales'))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$group = explode('.', $key)[0];
|
||||
|
||||
$groups[$group][] = [
|
||||
'key' => $key,
|
||||
'values' => $values,
|
||||
];
|
||||
}
|
||||
|
||||
$this->grouped = $groups;
|
||||
}
|
||||
|
||||
|
||||
public function updateField($index, $field, $value)
|
||||
{
|
||||
$item = $this->translations[$index];
|
||||
|
||||
if (!$item['id']) {
|
||||
$translation = Translation::create([
|
||||
'key' => $item['key'],
|
||||
'locale' => $this->locale,
|
||||
'value' => $value,
|
||||
]);
|
||||
|
||||
$this->translations[$index]['id'] = $translation->id;
|
||||
$this->translations[$index]['missing'] = false;
|
||||
} else {
|
||||
Translation::where('id', $item['id'])->update([
|
||||
$field => $value
|
||||
]);
|
||||
}
|
||||
|
||||
$this->translations[$index][$field] = $value;
|
||||
}
|
||||
|
||||
|
||||
public function updateValue($index, $value)
|
||||
{
|
||||
$item = $this->translations[$index];
|
||||
|
||||
Translation::where('id', $item['id'])->update([
|
||||
'value' => $value
|
||||
]);
|
||||
|
||||
// lokal updaten
|
||||
$this->translations[$index]['value'] = $value;
|
||||
}
|
||||
|
||||
public function addNew()
|
||||
{
|
||||
$translation = Translation::create([
|
||||
'key' => 'new.key.' . uniqid(),
|
||||
'locale' => $this->locale,
|
||||
'value' => '',
|
||||
]);
|
||||
|
||||
$this->translations[] = [
|
||||
'id' => $translation->id,
|
||||
'key' => $translation->key,
|
||||
'value' => '',
|
||||
'missing' => false, // 🔥 wichtig
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function deleteKey($key)
|
||||
{
|
||||
Translation::where('key', $key)->delete();
|
||||
|
||||
$this->loadTranslations();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.translations.index')
|
||||
->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Translations\Modals;
|
||||
|
||||
use App\Models\Translation;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class DeleteModal extends ModalComponent
|
||||
{
|
||||
public $key;
|
||||
public $count = 0;
|
||||
public $confirmKey = '';
|
||||
|
||||
public function mount($key)
|
||||
{
|
||||
$this->key = $key;
|
||||
$this->count = Translation::where('key', $key)->count();
|
||||
}
|
||||
|
||||
public function getCanDeleteProperty()
|
||||
{
|
||||
return $this->confirmKey === $this->key;
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
if (!$this->canDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
Translation::where('key', $this->key)->delete();
|
||||
|
||||
$this->dispatch('translations:updated');
|
||||
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.translations.modals.delete-modal');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Translations\Modals;
|
||||
|
||||
use App\Models\Translation;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class EditModal extends ModalComponent
|
||||
{
|
||||
public $key;
|
||||
public $values = [];
|
||||
|
||||
public function mount($key = null)
|
||||
{
|
||||
$this->key = $key;
|
||||
|
||||
foreach (config('app.locales') as $code => $label) {
|
||||
|
||||
$this->values[$code] = Translation::where('key', $key)
|
||||
->where('locale', $code)
|
||||
->value('value') ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
if (!$this->key) {
|
||||
$this->addError('key', 'Key erforderlich');
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->values as $locale => $value) {
|
||||
|
||||
Translation::updateOrCreate(
|
||||
[
|
||||
'key' => $this->key,
|
||||
'locale' => $locale,
|
||||
],
|
||||
[
|
||||
'value' => $value
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->dispatch('translations:updated');
|
||||
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.translations.modals.edit-modal');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Users;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use Livewire\Component;
|
||||
|
||||
class Calendar extends Component
|
||||
{
|
||||
public User $user;
|
||||
|
||||
public bool $sidebarOpen = false;
|
||||
public ?string $selectedEventId = null;
|
||||
|
||||
public function mount(User $user)
|
||||
{
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function openEvent(string $eventId)
|
||||
{
|
||||
$this->selectedEventId = $eventId;
|
||||
$this->sidebarOpen = true;
|
||||
}
|
||||
|
||||
public function closeSidebar()
|
||||
{
|
||||
$this->sidebarOpen = false;
|
||||
$this->selectedEventId = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$events = $this->user->events()
|
||||
->orderBy('starts_at', 'desc')
|
||||
->get();
|
||||
|
||||
$selectedEvent = $this->selectedEventId
|
||||
? Event::find($this->selectedEventId)
|
||||
: null;
|
||||
|
||||
return view('livewire.admin.users.calendar', compact('events', 'selectedEvent'))
|
||||
->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Users;
|
||||
|
||||
use App\Enums\SubscriptionStatus;
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\Activity;
|
||||
use App\Models\AgentLog;
|
||||
use App\Models\CreditTransaction;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class Detail extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public User $user;
|
||||
|
||||
// Credits
|
||||
public int $creditAmount = 0;
|
||||
public string $creditReason = '';
|
||||
|
||||
// Gift Access
|
||||
public string $giftPlanId = '';
|
||||
public string $giftDuration = '1_month';
|
||||
public string $giftReason = '';
|
||||
|
||||
// Rolle
|
||||
public string $newRole = '';
|
||||
|
||||
public function mount(User $user)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->newRole = $user->role->value;
|
||||
}
|
||||
|
||||
public function giveCredits()
|
||||
{
|
||||
$this->validate([
|
||||
'creditAmount' => 'required|integer|min:1|max:10000',
|
||||
'creditReason' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
CreditTransaction::adminGift(
|
||||
$this->user,
|
||||
$this->creditAmount,
|
||||
$this->creditReason,
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
$this->dispatch('notify', type: 'success', message: $this->creditAmount . ' Credits an ' . $this->user->name . ' vergeben.');
|
||||
|
||||
$this->creditAmount = 0;
|
||||
$this->creditReason = '';
|
||||
$this->user->refresh();
|
||||
}
|
||||
|
||||
public function giftAccess()
|
||||
{
|
||||
$this->validate([
|
||||
'giftPlanId' => 'required|exists:plans,id',
|
||||
'giftDuration' => 'required|in:1_month,3_months,6_months,1_year,forever',
|
||||
'giftReason' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$plan = Plan::findOrFail($this->giftPlanId);
|
||||
|
||||
$endsAt = match($this->giftDuration) {
|
||||
'1_month' => now()->addMonth(),
|
||||
'3_months' => now()->addMonths(3),
|
||||
'6_months' => now()->addMonths(6),
|
||||
'1_year' => now()->addYear(),
|
||||
'forever' => null,
|
||||
};
|
||||
|
||||
$durationLabel = match($this->giftDuration) {
|
||||
'1_month' => '1 Monat',
|
||||
'3_months' => '3 Monate',
|
||||
'6_months' => '6 Monate',
|
||||
'1_year' => '1 Jahr',
|
||||
'forever' => 'Unbegrenzt',
|
||||
};
|
||||
|
||||
// Bestehende aktive Subscriptions deaktivieren
|
||||
$this->user->subscriptions()
|
||||
->whereIn('status', [SubscriptionStatus::Active->value, SubscriptionStatus::Gifted->value])
|
||||
->update(['status' => SubscriptionStatus::Superseded->value]);
|
||||
|
||||
// Neue Geschenk-Subscription erstellen
|
||||
$this->user->subscriptions()->create([
|
||||
'plan_id' => $plan->id,
|
||||
'plan_name' => $plan->name,
|
||||
'plan_slug' => $plan->plan_key ?? str($plan->name)->slug(),
|
||||
'price' => 0,
|
||||
'interval' => 'gifted',
|
||||
'status' => SubscriptionStatus::Gifted->value,
|
||||
'starts_at' => now(),
|
||||
'ends_at' => $endsAt,
|
||||
'gifted_by' => auth()->id(),
|
||||
'gifted_at' => now(),
|
||||
'gift_reason' => $this->giftReason ?: null,
|
||||
]);
|
||||
|
||||
// Activity Log
|
||||
Activity::log(
|
||||
$this->user->id,
|
||||
'gift_access',
|
||||
"Admin hat {$plan->name} ({$durationLabel}) geschenkt",
|
||||
auth()->user()->name,
|
||||
meta: [
|
||||
'plan' => $plan->name,
|
||||
'duration' => $durationLabel,
|
||||
'ends_at' => $endsAt?->toDateString() ?? 'unbegrenzt',
|
||||
'gifted_by' => auth()->user()->name,
|
||||
'reason' => $this->giftReason,
|
||||
]
|
||||
);
|
||||
|
||||
// Mail senden
|
||||
try {
|
||||
Mail::to($this->user->email)->send(
|
||||
new \App\Mail\GiftAccessMail($this->user, $plan, $durationLabel, $endsAt)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
$this->dispatch('notify', type: 'success', message: "{$plan->name} für {$durationLabel} an {$this->user->name} vergeben.");
|
||||
|
||||
$this->giftPlanId = '';
|
||||
$this->giftDuration = '1_month';
|
||||
$this->giftReason = '';
|
||||
$this->user->refresh();
|
||||
}
|
||||
|
||||
public function revokeAccess()
|
||||
{
|
||||
$this->user->subscriptions()
|
||||
->whereIn('status', [SubscriptionStatus::Active->value, SubscriptionStatus::Gifted->value])
|
||||
->update([
|
||||
'status' => SubscriptionStatus::Canceled->value,
|
||||
'ends_at' => now(),
|
||||
]);
|
||||
|
||||
// Auf Free-Plan zurücksetzen
|
||||
$freePlan = Plan::freePlan();
|
||||
|
||||
if ($freePlan) {
|
||||
$this->user->subscriptions()->create([
|
||||
'plan_id' => $freePlan->id,
|
||||
'plan_name' => $freePlan->name,
|
||||
'plan_slug' => $freePlan->plan_key ?? str($freePlan->name)->slug(),
|
||||
'price' => 0,
|
||||
'interval' => 'monthly',
|
||||
'status' => SubscriptionStatus::Active->value,
|
||||
'starts_at' => now(),
|
||||
'ends_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
Activity::log(
|
||||
$this->user->id,
|
||||
'revoke_access',
|
||||
'Admin hat Zugang entzogen',
|
||||
auth()->user()->name,
|
||||
);
|
||||
|
||||
$this->dispatch('notify', type: 'success', message: "Zugang von {$this->user->name} wurde entzogen.");
|
||||
$this->user->refresh();
|
||||
}
|
||||
|
||||
public function deleteLog(string $logId)
|
||||
{
|
||||
$log = AgentLog::findOrFail($logId);
|
||||
abort_if($log->user_id !== $this->user->id, 403);
|
||||
|
||||
$credits = $log->credits;
|
||||
|
||||
CreditTransaction::refund(
|
||||
$this->user,
|
||||
$credits,
|
||||
'Log-Löschung durch Admin',
|
||||
$log->id,
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
$log->delete();
|
||||
|
||||
$this->user->refresh();
|
||||
$this->dispatch('notify', type: 'success', message: $credits . ' Credits wurden zurückerstattet.');
|
||||
}
|
||||
|
||||
public function updateStatus(string $status)
|
||||
{
|
||||
$this->user->update(['status' => $status]);
|
||||
$this->user->refresh();
|
||||
|
||||
$this->dispatch('notify', type: 'success', message: 'Status aktualisiert.');
|
||||
}
|
||||
|
||||
public function updateRole()
|
||||
{
|
||||
Gate::authorize('change-roles');
|
||||
|
||||
$this->validate([
|
||||
'newRole' => 'required|in:' . implode(',', array_column(UserRole::cases(), 'value')),
|
||||
]);
|
||||
|
||||
$newRole = UserRole::from($this->newRole);
|
||||
|
||||
// Super Admin kann sich nicht selbst degradieren
|
||||
if ($this->user->isSuperAdmin() && auth()->id() === $this->user->id) {
|
||||
$this->dispatch('notify', type: 'error', message: 'Du kannst deine eigene Super-Admin Rolle nicht ändern.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Niemand kann einen anderen Super Admin degradieren
|
||||
if ($this->user->isSuperAdmin() && $newRole !== UserRole::SuperAdmin) {
|
||||
$this->dispatch('notify', type: 'error', message: 'Super Admin Rollen können nicht geändert werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
$oldRole = $this->user->role;
|
||||
$this->user->update(['role' => $newRole->value]);
|
||||
|
||||
// Auto-Plan-Zuweisung passend zur neuen Rolle
|
||||
$this->assignPlanForRole($newRole);
|
||||
|
||||
Activity::log(
|
||||
$this->user->id,
|
||||
'role_change',
|
||||
"Rolle geändert: {$oldRole->label()} → {$newRole->label()}",
|
||||
auth()->user()->name,
|
||||
meta: [
|
||||
'old_role' => $oldRole->value,
|
||||
'new_role' => $newRole->value,
|
||||
'changed_by' => auth()->user()->name,
|
||||
]
|
||||
);
|
||||
|
||||
$this->user->refresh();
|
||||
$this->dispatch('notify', type: 'success', message: "Rolle auf {$newRole->label()} geändert.");
|
||||
}
|
||||
|
||||
private function assignPlanForRole(UserRole $role): void
|
||||
{
|
||||
$planKey = match($role) {
|
||||
UserRole::SuperAdmin => 'internal',
|
||||
UserRole::Admin => 'internal',
|
||||
UserRole::Developer => 'developer',
|
||||
UserRole::Support => 'support',
|
||||
UserRole::Affiliate => 'affiliate',
|
||||
UserRole::BetaTester => 'beta_tester',
|
||||
UserRole::User => null,
|
||||
};
|
||||
|
||||
if ($planKey === null) {
|
||||
$plan = Plan::freePlan();
|
||||
} else {
|
||||
$plan = Plan::where('plan_key', $planKey)->first();
|
||||
}
|
||||
|
||||
if (!$plan) return;
|
||||
|
||||
// Bestehende aktive Subscription deaktivieren
|
||||
$this->user->subscriptions()
|
||||
->whereIn('status', [SubscriptionStatus::Active->value, SubscriptionStatus::Gifted->value])
|
||||
->update(['status' => SubscriptionStatus::Superseded->value]);
|
||||
|
||||
// Neuen Plan zuweisen
|
||||
$this->user->subscriptions()->create([
|
||||
'plan_id' => $plan->id,
|
||||
'plan_name' => $plan->name,
|
||||
'plan_slug' => $plan->plan_key ?? str($plan->name)->slug(),
|
||||
'price' => 0,
|
||||
'interval' => $planKey ? 'internal' : 'monthly',
|
||||
'status' => SubscriptionStatus::Active->value,
|
||||
'starts_at' => now(),
|
||||
'ends_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$stats = [
|
||||
'events' => $this->user->events()->count(),
|
||||
'notes' => $this->user->notes()->count(),
|
||||
'tasks' => $this->user->tasks()->count(),
|
||||
'contacts' => $this->user->contacts()->count(),
|
||||
'creditsUsed' => $this->user->agentLogs()->where('created_at', '>=', now()->startOfMonth())->sum('credits'),
|
||||
'totalCost' => $this->user->agentLogs()->sum('cost_usd'),
|
||||
'lastActive' => $this->user->agentLogs()->latest()->value('created_at'),
|
||||
];
|
||||
|
||||
$logs = $this->user->agentLogs()->latest()->paginate(10);
|
||||
|
||||
$activeSub = $this->user->subscriptions()
|
||||
->whereIn('status', [SubscriptionStatus::Active->value, SubscriptionStatus::Gifted->value])
|
||||
->with(['plan', 'gifter'])
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
$plans = Plan::public()->where('price', '>', 0)->orderBy('price')->get();
|
||||
|
||||
$creditTx = $this->user->creditTransactions()->with('creator')->latest()->limit(15)->get();
|
||||
|
||||
return view('livewire.admin.users.detail', compact('stats', 'logs', 'activeSub', 'plans', 'creditTx'))
|
||||
->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Users;
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
public string $tab = 'all';
|
||||
|
||||
protected $queryString = [
|
||||
'search' => ['except' => ''],
|
||||
'tab' => ['except' => 'all'],
|
||||
];
|
||||
|
||||
public function updatingSearch()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function setTab(string $tab)
|
||||
{
|
||||
$this->tab = $tab;
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updateStatus(string $userId, string $status)
|
||||
{
|
||||
$user = User::findOrFail($userId);
|
||||
$user->update(['status' => $status]);
|
||||
|
||||
$this->dispatch('notify', type: 'success', message: 'Status aktualisiert.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$query = User::withCount(['events', 'agentLogs'])
|
||||
->with('subscription.plan')
|
||||
->when($this->search, fn($q) =>
|
||||
$q->where('name', 'like', '%' . $this->search . '%')
|
||||
->orWhere('email', 'like', '%' . $this->search . '%')
|
||||
);
|
||||
|
||||
// Tab-Counts (vor Status-Filter)
|
||||
$baseQuery = User::when($this->search, fn($q) =>
|
||||
$q->where('name', 'like', '%' . $this->search . '%')
|
||||
->orWhere('email', 'like', '%' . $this->search . '%')
|
||||
);
|
||||
|
||||
$counts = [
|
||||
'all' => (clone $baseQuery)->count(),
|
||||
'active' => (clone $baseQuery)->where('status', 'active')->count(),
|
||||
'suspended' => (clone $baseQuery)->where('status', 'suspended')->count(),
|
||||
'blocked' => (clone $baseQuery)->where('status', 'blocked')->count(),
|
||||
'staff' => (clone $baseQuery)->whereNotIn('role', ['user'])->count(),
|
||||
];
|
||||
|
||||
// Tab-Filter anwenden
|
||||
if ($this->tab === 'staff') {
|
||||
$query->whereNotIn('role', ['user']);
|
||||
} elseif ($this->tab !== 'all') {
|
||||
$query->where('status', $this->tab);
|
||||
}
|
||||
|
||||
$users = $query->latest()->paginate(25);
|
||||
|
||||
return view('livewire.admin.users.index', compact('users', 'counts'))
|
||||
->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\AppVersion;
|
||||
use Livewire\Component;
|
||||
|
||||
class Versions extends Component
|
||||
{
|
||||
public string $version = '';
|
||||
public string $name = '';
|
||||
public string $changelog = '';
|
||||
public string $status = 'draft';
|
||||
public string $platform = 'all';
|
||||
public bool $show_popup = true;
|
||||
public ?string $editingId = null;
|
||||
|
||||
public function getVersionsProperty()
|
||||
{
|
||||
return AppVersion::latest()->get();
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate([
|
||||
'version' => 'required',
|
||||
'name' => 'required',
|
||||
'status' => 'required|in:draft,beta,live,rollback',
|
||||
'platform' => 'required|in:all,web,app',
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'version' => $this->version,
|
||||
'name' => $this->name,
|
||||
'changelog' => $this->changelog,
|
||||
'status' => $this->status,
|
||||
'platform' => $this->platform,
|
||||
'show_popup' => $this->show_popup,
|
||||
'released_at' => $this->status === 'live' ? now() : null,
|
||||
];
|
||||
|
||||
if ($this->editingId) {
|
||||
AppVersion::find($this->editingId)->update($data);
|
||||
} else {
|
||||
AppVersion::create($data);
|
||||
}
|
||||
|
||||
$this->reset(['version', 'name', 'changelog', 'show_popup', 'editingId']);
|
||||
$this->status = 'draft';
|
||||
$this->platform = 'all';
|
||||
}
|
||||
|
||||
public function setLive(string $id): void
|
||||
{
|
||||
AppVersion::find($id)->update([
|
||||
'status' => 'live',
|
||||
'released_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(string $id): void
|
||||
{
|
||||
$v = AppVersion::find($id);
|
||||
$this->editingId = $id;
|
||||
$this->version = $v->version;
|
||||
$this->name = $v->name;
|
||||
$this->changelog = $v->changelog ?? '';
|
||||
$this->status = $v->status;
|
||||
$this->platform = $v->platform;
|
||||
$this->show_popup = $v->show_popup;
|
||||
}
|
||||
|
||||
public function delete(string $id): void
|
||||
{
|
||||
AppVersion::find($id)->delete();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.versions')
|
||||
->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Agent;
|
||||
|
||||
use App\Models\AgentLog;
|
||||
use Livewire\Component;
|
||||
|
||||
class History extends Component
|
||||
{
|
||||
public $history = [];
|
||||
|
||||
protected $listeners = ['agent:sent' => 'reload'];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->load();
|
||||
}
|
||||
|
||||
public function reload(): void
|
||||
{
|
||||
$this->load();
|
||||
}
|
||||
|
||||
private function load(): void
|
||||
{
|
||||
$this->history = AgentLog::where('user_id', auth()->id())
|
||||
->latest()
|
||||
->limit(8)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.agent.history');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Agent;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\AgentLog;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Event;
|
||||
use App\Models\Note;
|
||||
use App\Models\Task;
|
||||
use App\Services\AgentActionService;
|
||||
use App\Services\AgentAIService;
|
||||
use App\Services\AgentContextService;
|
||||
use Carbon\Carbon;
|
||||
use Livewire\Component;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
public string $message = '';
|
||||
|
||||
public $user;
|
||||
public $subscription;
|
||||
public $plan;
|
||||
|
||||
public int $usage = 0;
|
||||
public int $limit = 0;
|
||||
public int $usagePercent = 0;
|
||||
|
||||
public ?array $lastAction = null;
|
||||
|
||||
public array $conversation = [];
|
||||
|
||||
// Token-Tracking über die gesamte Konversation hinweg
|
||||
public int $conversationPromptTokens = 0;
|
||||
public int $conversationCompletionTokens = 0;
|
||||
public int $conversationTotalTokens = 0;
|
||||
public float $conversationCostUsd = 0;
|
||||
|
||||
protected $listeners = [
|
||||
'agent:result' => 'handleAgentResult',
|
||||
];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->user = auth()->user();
|
||||
|
||||
$this->subscription = $this->user->subscription()->with('plan')->first()
|
||||
?? $this->user->latestSubscription()->with('plan')->first();
|
||||
|
||||
$this->plan = $this->subscription?->plan;
|
||||
$this->limit = $this->user->effective_limit;
|
||||
|
||||
$this->recalcUsage();
|
||||
}
|
||||
|
||||
public function send(string $text = '', bool $withAudio = false): ?array
|
||||
{
|
||||
$userMessage = trim($text ?: $this->message);
|
||||
if (!$userMessage) return null;
|
||||
|
||||
$this->reset('message');
|
||||
|
||||
$hardLimit = (int) ceil($this->limit * 1.05);
|
||||
|
||||
// Stufe 1: Hart blockieren ab 105%
|
||||
if ($this->limit > 0 && $this->usage >= $hardLimit) {
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'error',
|
||||
'title' => 'Limit erreicht',
|
||||
'message' => 'Dein monatliches Kontingent ist aufgebraucht. Upgrade auf Pro oder warte bis zum nächsten Monat.',
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stufe 2: Warnung bei 100%-105% — Anfrage läuft durch
|
||||
if ($this->limit > 0 && $this->usage >= $this->limit) {
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'warning',
|
||||
'title' => 'Fast aufgebraucht',
|
||||
'message' => 'Du nutzt deine Toleranzreserve. Dein Kontingent ist bald erschöpft.',
|
||||
]);
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
$aiConfig = $this->plan?->ai_config ?? [];
|
||||
if (is_string($aiConfig)) {
|
||||
$aiConfig = json_decode($aiConfig, true);
|
||||
}
|
||||
|
||||
$model = $aiConfig['model'] ?? 'gpt-4o-mini';
|
||||
|
||||
$historyForAI = array_slice($this->conversation, -8);
|
||||
$messagesForAI = array_merge($historyForAI, [
|
||||
['role' => 'user', 'content' => $userMessage],
|
||||
]);
|
||||
|
||||
// User-Kontext (geteilter Service → Web + API identisch)
|
||||
$userContext = app(AgentContextService::class)->build($this->user);
|
||||
\Log::info('UserContext Länge: ' . str_word_count($userContext) . ' Wörter, ' . strlen($userContext) . ' Zeichen');
|
||||
|
||||
$parsed = AgentAIService::chat($messagesForAI, $aiConfig, $userContext);
|
||||
$usage = $parsed['_usage'] ?? [];
|
||||
|
||||
$this->conversationPromptTokens += $usage['prompt_tokens'] ?? 0;
|
||||
$this->conversationCompletionTokens += $usage['completion_tokens'] ?? 0;
|
||||
$this->conversationTotalTokens += $usage['total_tokens'] ?? 0;
|
||||
$this->conversationCostUsd += $this->calculateCost($usage, $model);
|
||||
|
||||
try {
|
||||
// ── Multi-Action: Array von Aktionen ──────────────────────────
|
||||
if (isset($parsed['_multi'])) {
|
||||
$actions = $parsed['_multi'];
|
||||
$results = [];
|
||||
$messages = [];
|
||||
|
||||
foreach ($actions as $action) {
|
||||
if (!isset($action['type'])) continue;
|
||||
$result = AgentActionService::handle($this->user, $action);
|
||||
$results[] = $result;
|
||||
|
||||
if ($result['status'] === 'success') {
|
||||
$messages[] = $result['message'] ?? 'Erledigt';
|
||||
|
||||
if (in_array($action['type'], ['event', 'event_update'])) {
|
||||
$this->dispatch('eventCreated');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$duration = round((microtime(true) - $startTime) * 1000);
|
||||
$credits = $this->calculateCredits(['type' => 'multi'], $duration, $usage);
|
||||
|
||||
$combinedResult = [
|
||||
'status' => 'success',
|
||||
'message' => implode(' | ', $messages) ?: 'Erledigt!',
|
||||
'meta' => ['actions' => count($actions)],
|
||||
];
|
||||
|
||||
$this->logConversationAction($userMessage, ['type' => 'multi'], $combinedResult, $model, $duration, $credits);
|
||||
|
||||
$assistantMsg = implode('. ', $messages) . '. Kann ich noch etwas für dich tun?';
|
||||
$this->conversation[] = ['role' => 'user', 'content' => $userMessage];
|
||||
$this->conversation[] = ['role' => 'assistant', 'content' => $assistantMsg];
|
||||
|
||||
if (str_contains($assistantMsg, '[END]')) {
|
||||
$this->dispatch('conversation-ended');
|
||||
}
|
||||
|
||||
$this->applyResult($combinedResult);
|
||||
$this->dispatch('agent:sent');
|
||||
|
||||
} elseif (($parsed['type'] ?? 'unknown') === 'chat') {
|
||||
// ── Chat-Antwort: Log nur bei [END] ohne vorherige Aktion ──
|
||||
$chatMessage = $parsed['data']['message'] ?? 'Hmm, da bin ich überfragt.';
|
||||
|
||||
$this->conversation[] = ['role' => 'user', 'content' => $userMessage];
|
||||
$this->conversation[] = ['role' => 'assistant', 'content' => $chatMessage];
|
||||
|
||||
if (str_contains($chatMessage, '[END]')) {
|
||||
// Gab es in dieser Session bereits eine geloggte Aktion?
|
||||
$hadAction = collect($this->conversation)
|
||||
->where('role', 'assistant')
|
||||
->filter(fn($msg) => !str_contains($msg['content'], '[END]'))
|
||||
->count() > 1;
|
||||
|
||||
if (!$hadAction) {
|
||||
$duration = round((microtime(true) - $startTime) * 1000);
|
||||
$this->logConversationAction(
|
||||
collect($this->conversation)
|
||||
->where('role', 'user')
|
||||
->first()['content'] ?? $userMessage,
|
||||
['type' => 'chat', 'data' => []],
|
||||
['status' => 'success', 'message' => 'Chat', 'meta' => []],
|
||||
$model,
|
||||
$duration,
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
$this->dispatch('conversation-ended');
|
||||
}
|
||||
|
||||
$this->lastAction = null;
|
||||
|
||||
} else {
|
||||
// ── Einzelne Aktion (event, note, task) ───────────────────────
|
||||
$result = AgentActionService::handle($this->user, $parsed);
|
||||
$duration = round((microtime(true) - $startTime) * 1000);
|
||||
|
||||
// Mehrdeutig → AI soll nachfragen (als Chat-Antwort behandeln)
|
||||
if ($result['status'] === 'ambiguous') {
|
||||
$this->conversation[] = ['role' => 'user', 'content' => $userMessage];
|
||||
$this->conversation[] = ['role' => 'assistant', 'content' => $result['message']];
|
||||
$this->lastAction = null;
|
||||
|
||||
} elseif ($result['status'] === 'conflict') {
|
||||
$this->logConversationAction($userMessage, $parsed, $result, $model, $duration, 0);
|
||||
|
||||
$this->conversation[] = ['role' => 'user', 'content' => $userMessage];
|
||||
$this->conversation[] = ['role' => 'assistant', 'content' => 'Es gibt einen Terminkonflikt. Ich zeige dir die Details.'];
|
||||
|
||||
$this->dispatch('openModal', 'agent.modals.conflict-modal', [
|
||||
'data' => $parsed['data'],
|
||||
'meta' => $result['meta'],
|
||||
'originalInput' => $userMessage,
|
||||
]);
|
||||
|
||||
$this->dispatch('agent:sent');
|
||||
|
||||
} else {
|
||||
$credits = $this->calculateCredits($parsed, $duration, $usage);
|
||||
$this->logConversationAction($userMessage, $parsed, $result, $model, $duration, $credits);
|
||||
|
||||
if ($result['status'] === 'success' && ($parsed['type'] ?? '') === 'event') {
|
||||
$this->dispatch('eventCreated');
|
||||
|
||||
Activity::log(
|
||||
$this->user->id,
|
||||
Activity::TYPE_EVENT_CREATED,
|
||||
Activity::localizedTitle('event_created_assistant', $this->user->locale ?? 'de'),
|
||||
$result['meta']['title'] ?? null,
|
||||
meta: [
|
||||
'start' => $result['meta']['start'] ?? null,
|
||||
'credits' => $credits,
|
||||
'duration' => $result['meta']['duration'] ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Natürliche Bestätigung
|
||||
$confirmMsg = $result['status'] === 'success'
|
||||
? ($result['message'] ?? 'Erledigt!') . ' Kann ich noch etwas für dich tun?'
|
||||
: ($result['message'] ?? 'Da hat etwas nicht geklappt.');
|
||||
|
||||
$this->conversation[] = ['role' => 'user', 'content' => $userMessage];
|
||||
$this->conversation[] = ['role' => 'assistant', 'content' => $confirmMsg];
|
||||
|
||||
if (str_contains($confirmMsg, '[END]')) {
|
||||
$this->dispatch('conversation-ended');
|
||||
}
|
||||
|
||||
$this->applyResult($result);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$duration = round((microtime(true) - $startTime) * 1000);
|
||||
|
||||
$this->logConversationAction($userMessage, $parsed, [
|
||||
'status' => 'failed',
|
||||
'meta' => ['error' => $e->getMessage()],
|
||||
], $model, $duration, 0);
|
||||
|
||||
$this->conversation[] = ['role' => 'user', 'content' => $userMessage];
|
||||
$this->conversation[] = ['role' => 'assistant', 'content' => 'Da ist leider etwas schiefgelaufen.'];
|
||||
|
||||
$this->applyResult([
|
||||
'status' => 'failed',
|
||||
'message' => 'Fehler bei der Verarbeitung',
|
||||
'meta' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->dispatch('agent:sent');
|
||||
|
||||
// TTS im selben Request mitsynthetisieren (spart einen Round-Trip)
|
||||
if ($withAudio && !empty($this->conversation)) {
|
||||
$lastMsg = end($this->conversation);
|
||||
if ($lastMsg['role'] === 'assistant') {
|
||||
$spokenText = trim(str_replace('[END]', '', $lastMsg['content']));
|
||||
if ($spokenText) {
|
||||
$audio = AgentAIService::textToSpeech($spokenText, $aiConfig);
|
||||
return ['audio' => $audio];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function synthesize(string $text): array
|
||||
{
|
||||
$aiConfig = $this->plan?->ai_config ?? [];
|
||||
if (is_string($aiConfig)) {
|
||||
$aiConfig = json_decode($aiConfig, true);
|
||||
}
|
||||
|
||||
$audio = AgentAIService::textToSpeech($text, $aiConfig);
|
||||
|
||||
return ['audio' => $audio];
|
||||
}
|
||||
|
||||
public function clearConversation(): void
|
||||
{
|
||||
$this->conversation = [];
|
||||
$this->lastAction = null;
|
||||
$this->conversationPromptTokens = 0;
|
||||
$this->conversationCompletionTokens = 0;
|
||||
$this->conversationTotalTokens = 0;
|
||||
$this->conversationCostUsd = 0;
|
||||
}
|
||||
|
||||
public function handleAgentResult(array $result): void
|
||||
{
|
||||
$this->applyResult($result);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.agent.index')->layout('layouts.app');
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────
|
||||
|
||||
private function extractChatTitle(string $userMessage): string
|
||||
{
|
||||
$farewells = [
|
||||
'nein danke', 'nein', 'danke', 'passt', 'das wars',
|
||||
'ok danke', 'okay danke', 'super danke', 'alles klar',
|
||||
'nichts mehr', 'tschüss', 'bye', 'ciao',
|
||||
];
|
||||
|
||||
$isGoodbye = in_array(strtolower(trim($userMessage)), $farewells);
|
||||
|
||||
if ($isGoodbye && count($this->conversation) > 0) {
|
||||
foreach ($this->conversation as $msg) {
|
||||
if ($msg['role'] === 'user') {
|
||||
return mb_substr($msg['content'], 0, 80);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mb_substr($userMessage, 0, 80);
|
||||
}
|
||||
|
||||
private function logConversationAction(
|
||||
string $userMessage,
|
||||
array $parsed,
|
||||
array $result,
|
||||
string $model,
|
||||
int $duration,
|
||||
int $credits,
|
||||
): void {
|
||||
$input = match ($parsed['type']) {
|
||||
'event', 'event_update' => $parsed['data']['title'] ?? $userMessage,
|
||||
'note', 'note_update' => mb_substr($parsed['data']['content'] ?? $userMessage, 0, 100),
|
||||
'task', 'task_update' => $parsed['data']['title'] ?? $userMessage,
|
||||
'contact' => $parsed['data']['name'] ?? $userMessage,
|
||||
'email' => 'E-Mail an ' . ($parsed['data']['contact'] ?? '') . ': ' . mb_substr($parsed['data']['subject'] ?? '', 0, 50),
|
||||
'multi' => $userMessage,
|
||||
'chat' => $this->extractChatTitle($userMessage),
|
||||
default => mb_substr($userMessage, 0, 200),
|
||||
};
|
||||
|
||||
AgentLog::create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => $parsed['type'],
|
||||
'input' => $input,
|
||||
'status' => $result['status'],
|
||||
'output' => $result['meta'] ?? null,
|
||||
'credits' => $credits,
|
||||
'ai_response' => $parsed,
|
||||
'model' => $model,
|
||||
'duration_ms' => $duration,
|
||||
'prompt_tokens' => $this->conversationPromptTokens,
|
||||
'completion_tokens' => $this->conversationCompletionTokens,
|
||||
'total_tokens' => $this->conversationTotalTokens,
|
||||
'cost_usd' => $this->conversationCostUsd,
|
||||
]);
|
||||
|
||||
$this->recalcUsage();
|
||||
|
||||
if ($this->limit > 0 && $this->usage >= $this->limit) {
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'warning',
|
||||
'title' => 'Kontingent aufgebraucht',
|
||||
'message' => 'Dein Kontingent ist erschöpft. Upgrade auf Pro oder warte bis zum nächsten Monat.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function applyResult(array $result): void
|
||||
{
|
||||
$this->lastAction = [
|
||||
'status' => $result['status'],
|
||||
'message' => $result['message'],
|
||||
'meta' => $result['meta'] ?? [],
|
||||
'time' => now(),
|
||||
];
|
||||
|
||||
$this->recalcUsage();
|
||||
}
|
||||
|
||||
private function recalcUsage(): void
|
||||
{
|
||||
$this->user->refresh();
|
||||
|
||||
if ($this->user->effective_limit === 0) {
|
||||
$this->usage = 0;
|
||||
$this->usagePercent = 0;
|
||||
$this->limit = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->limit = $this->user->effective_limit;
|
||||
$this->usage = $this->user->effective_usage;
|
||||
$this->usagePercent = $this->user->usage_percent;
|
||||
}
|
||||
|
||||
private function calculateCost(array $usage, string $model): float
|
||||
{
|
||||
$aiConfig = $this->plan?->ai_config ?? [];
|
||||
if (is_string($aiConfig)) {
|
||||
$aiConfig = json_decode($aiConfig, true);
|
||||
}
|
||||
|
||||
$inputCost = ($usage['prompt_tokens'] ?? 0) / 1000 * ($aiConfig['input_cost'] ?? 0.00015);
|
||||
$outputCost = ($usage['completion_tokens'] ?? 0) / 1000 * ($aiConfig['output_cost'] ?? 0.0006);
|
||||
|
||||
return round($inputCost + $outputCost, 6);
|
||||
}
|
||||
|
||||
private function calculateCredits(array $parsed, int $duration, array $usage): int
|
||||
{
|
||||
// Flat-Basis + Output-Tokens. Kontext (prompt_tokens) wird ignoriert,
|
||||
// damit Credits nicht mit Kalendergröße skalieren. Cap bei 100.
|
||||
$completionTokens = (int) ($usage['completion_tokens'] ?? 0);
|
||||
$credits = 20 + (int) ceil($completionTokens * 0.3);
|
||||
|
||||
return min(100, max(1, $credits));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Agent;
|
||||
|
||||
use App\Models\AgentLog;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class Logs extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
public string $filterStatus = ''; // '' | success | failed | conflict | duplicate | processing
|
||||
|
||||
public function updatedSearch(): void { $this->resetPage(); }
|
||||
public function updatedFilterStatus(): void { $this->resetPage(); }
|
||||
|
||||
public function render()
|
||||
{
|
||||
$userId = auth()->id();
|
||||
$tz = auth()->user()->timezone ?? 'UTC';
|
||||
|
||||
$query = AgentLog::where('user_id', $userId)->latest();
|
||||
|
||||
if ($this->search !== '') {
|
||||
$query->where('input', 'like', '%' . $this->search . '%');
|
||||
}
|
||||
|
||||
if ($this->filterStatus !== '') {
|
||||
$query->where('status', $this->filterStatus);
|
||||
}
|
||||
|
||||
$logs = $query->paginate(25);
|
||||
|
||||
// Monats-Statistiken
|
||||
$stats = AgentLog::where('user_id', $userId)
|
||||
->where('created_at', '>=', now()->startOfMonth())
|
||||
->selectRaw("
|
||||
count(*) as total,
|
||||
sum(credits) as credits_used,
|
||||
sum(cost_usd) as total_cost,
|
||||
sum(total_tokens) as total_tokens,
|
||||
sum(case when status = 'success' then 1 else 0 end) as successes,
|
||||
sum(case when status = 'failed' then 1 else 0 end) as failures,
|
||||
avg(duration_ms) as avg_duration
|
||||
")
|
||||
->first();
|
||||
|
||||
// Credit-Limit für Fortschrittsanzeige
|
||||
$user = auth()->user();
|
||||
$limit = $user->effective_limit;
|
||||
$usage = $user->effective_usage;
|
||||
$usagePercent = $user->usage_percent;
|
||||
|
||||
return view('livewire.agent.logs', [
|
||||
'logs' => $logs,
|
||||
'stats' => $stats,
|
||||
'tz' => $tz,
|
||||
'usage' => $usage,
|
||||
'limit' => $limit,
|
||||
'usagePercent' => $usagePercent,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Livewire\Agent\Modals;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Services\AgentActionService;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class ConflictModal extends ModalComponent
|
||||
{
|
||||
public $data = [];
|
||||
public $meta = [];
|
||||
public $originalInput = '';
|
||||
|
||||
public $selectedSlot = null; // 'prev' | 'next' | null
|
||||
|
||||
public function mount($data, $meta, $originalInput)
|
||||
{
|
||||
$this->data = $data;
|
||||
$this->meta = $meta;
|
||||
$this->originalInput = $originalInput;
|
||||
}
|
||||
|
||||
public function selectSlot($type)
|
||||
{
|
||||
$this->selectedSlot = $this->selectedSlot === $type ? null : $type;
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
// 👉 SLOT ÜBERNEHMEN
|
||||
if ($this->selectedSlot === 'prev' && !empty($this->meta['suggestion_prev'])) {
|
||||
$this->data['datetime'] = $this->meta['suggestion_prev'];
|
||||
$this->data['duration_minutes'] = $this->meta['duration'];
|
||||
} elseif ($this->selectedSlot === 'next' && !empty($this->meta['suggestion_next'])) {
|
||||
$this->data['datetime'] = $this->meta['suggestion_next'];
|
||||
$this->data['duration_minutes'] = $this->meta['duration'];
|
||||
}
|
||||
|
||||
// 👉 IMMER force = true (weil Konflikt bewusst)
|
||||
$this->data['force'] = true;
|
||||
|
||||
$result = AgentActionService::handle(auth()->user(), [
|
||||
'type' => 'event',
|
||||
'data' => $this->data,
|
||||
]);
|
||||
|
||||
if ($result['status'] === 'success') {
|
||||
$this->dispatch('eventCreated');
|
||||
|
||||
Activity::log(
|
||||
auth()->id(),
|
||||
Activity::TYPE_EVENT_CREATED,
|
||||
Activity::localizedTitle('event_created_assistant', auth()->user()->locale ?? 'de'),
|
||||
$result['meta']['title'] ?? null,
|
||||
meta: ['start' => $result['meta']['start'] ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$this->dispatch('agent:result', $result);
|
||||
$this->dispatch('closeModal');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.agent.modals.conflict-modal');
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
//namespace App\Livewire\Agent\Modals;
|
||||
//
|
||||
//use App\Services\AgentActionService;
|
||||
//use LivewireUI\Modal\ModalComponent;
|
||||
//
|
||||
//class ConflictModal extends ModalComponent
|
||||
//{
|
||||
// public $data = [];
|
||||
// public $meta = [];
|
||||
// public $originalInput = '';
|
||||
// public $selectedSlot = null; // 'prev' | 'next' | null
|
||||
//
|
||||
// public function mount($data, $meta, $originalInput)
|
||||
// {
|
||||
// $this->data = $data;
|
||||
// $this->meta = $meta;
|
||||
// $this->originalInput = $originalInput;
|
||||
// }
|
||||
//
|
||||
// public function selectSlot($type)
|
||||
// {
|
||||
// if ($this->selectedSlot === $type) {
|
||||
// // toggle off
|
||||
// $this->selectedSlot = null;
|
||||
// } else {
|
||||
// $this->selectedSlot = $type;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public function forceSave()
|
||||
// {
|
||||
// $this->data['force'] = true;
|
||||
//
|
||||
// $result = AgentActionService::handle(auth()->user(), [
|
||||
// 'type' => 'event',
|
||||
// 'data' => $this->data
|
||||
// ]);
|
||||
//
|
||||
// $this->dispatch('agent:result', $result);
|
||||
// $this->dispatch('closeModal');
|
||||
// }
|
||||
//
|
||||
// public function acceptSuggestion($type)
|
||||
// {
|
||||
// if ($type === 'next' && !empty($this->meta['suggestion_next'])) {
|
||||
// $this->data['start'] = $this->meta['suggestion_next'];
|
||||
// }
|
||||
//
|
||||
// if ($type === 'prev' && !empty($this->meta['suggestion_prev'])) {
|
||||
// $this->data['start'] = $this->meta['suggestion_prev'];
|
||||
// }
|
||||
//
|
||||
// $this->data['force'] = true;
|
||||
//
|
||||
// $result = AgentActionService::handle(auth()->user(), [
|
||||
// 'type' => 'event',
|
||||
// 'data' => $this->data
|
||||
// ]);
|
||||
//
|
||||
// $this->dispatch('agent:result', $result);
|
||||
// $this->dispatch('closeModal');
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.agent.modals.conflict-modal');
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Mail\ResetPasswordMail;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
|
||||
class ForgotPassword extends Component
|
||||
{
|
||||
public string $email = '';
|
||||
public bool $sent = false;
|
||||
public string $error = '';
|
||||
|
||||
public function send(): void
|
||||
{
|
||||
$this->validate([
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
$user = User::where('email', $this->email)->first();
|
||||
|
||||
if (!$user) {
|
||||
// Gleiche Meldung aus Security-Gründen
|
||||
$this->sent = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$token = Str::random(64);
|
||||
|
||||
DB::table('password_reset_tokens')->updateOrInsert(
|
||||
['email' => $this->email],
|
||||
['token' => Hash::make($token), 'created_at' => now()]
|
||||
);
|
||||
|
||||
Mail::mailer('system')->to($this->email)->send(new ResetPasswordMail($user, $token));
|
||||
|
||||
$this->sent = true;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.forgot-password')
|
||||
->layout('layouts.blank');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class Login extends Component
|
||||
{
|
||||
public $email;
|
||||
public $password;
|
||||
public $email_verified_at;
|
||||
|
||||
public function login()
|
||||
{
|
||||
$this->validate(
|
||||
[
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
],
|
||||
[
|
||||
'email.required' => t('auth.validation.email_required'),
|
||||
'email.email' => t('auth.validation.email_invalid'),
|
||||
'password.required' => t('auth.validation.password_required'),
|
||||
]
|
||||
);
|
||||
|
||||
if (!Auth::attempt([
|
||||
'email' => $this->email,
|
||||
'password' => $this->password
|
||||
])) {
|
||||
$this->addError('email', t('auth.login.invalid_credentials'));
|
||||
return;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->email_verified_at) {
|
||||
Auth::logout(); // 🔥 wichtig!
|
||||
return redirect()->route('verify.notice', [
|
||||
'user' => $user->id
|
||||
]);
|
||||
}
|
||||
|
||||
request()->session()->regenerate();
|
||||
|
||||
return redirect()->route('dashboard.index');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.login')
|
||||
->layout('layouts.blank');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth\Modals;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\VerificationService;
|
||||
use Livewire\Component;
|
||||
|
||||
class VerifyModal extends Component
|
||||
{
|
||||
public $code;
|
||||
public $user;
|
||||
|
||||
public function mount($userId)
|
||||
{
|
||||
$this->user = User::findOrFail($userId);
|
||||
}
|
||||
|
||||
public function verify()
|
||||
{
|
||||
if (!VerificationService::verify($this->user, $this->code)) {
|
||||
$this->addError('code', 'Ungültiger Code');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('closeModal');
|
||||
|
||||
return redirect()->route('dashboard.index');
|
||||
}
|
||||
|
||||
public function resend()
|
||||
{
|
||||
VerificationService::create($this->user);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.modals.verify-modal');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Models\Plan;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use App\Services\MailService;
|
||||
use App\Services\VerificationService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
|
||||
class Register extends Component
|
||||
{
|
||||
public $name;
|
||||
public $email;
|
||||
public $password;
|
||||
public $timezone = 'UTC';
|
||||
|
||||
public function register()
|
||||
{
|
||||
$this->validate(
|
||||
[
|
||||
'name' => ['required'],
|
||||
'email' => ['required', 'email', 'unique:users,email'],
|
||||
'password' => ['required', 'min:6'],
|
||||
],
|
||||
[
|
||||
// NAME
|
||||
'name.required' => t('auth.validation.name_required'),
|
||||
|
||||
// EMAIL
|
||||
'email.required' => t('auth.validation.email_required'),
|
||||
'email.email' => t('auth.validation.email_invalid'),
|
||||
'email.unique' => t('auth.validation.email_taken'),
|
||||
|
||||
// PASSWORD
|
||||
'password.required' => t('auth.validation.password_required'),
|
||||
'password.min' => t('auth.validation.password_min'),
|
||||
]
|
||||
);
|
||||
|
||||
// 🔥 Browser Sprache holen
|
||||
$locale = substr(request()->header('Accept-Language'), 0, 2);
|
||||
$locale = in_array($locale, array_keys(config('app.locales')))
|
||||
? $locale
|
||||
: config('app.default_locale');
|
||||
|
||||
// 🔥 Timezone absichern
|
||||
if (!in_array($this->timezone, timezone_identifiers_list())) {
|
||||
$this->timezone = 'UTC';
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'password' => Hash::make($this->password),
|
||||
'locale' => $locale,
|
||||
'timezone' => $this->timezone,
|
||||
]);
|
||||
|
||||
$plan = Plan::where('name', 'Free')
|
||||
->where('active', true)
|
||||
->firstOrFail();
|
||||
|
||||
// SUBSCRIPTION ANLEGEN (eine Zeile pro User, wird bei Planwechsel nur geupdated)
|
||||
Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'plan_name' => $plan->name,
|
||||
'plan_slug' => $plan->plan_key ?? Str::slug($plan->name),
|
||||
'price' => $plan->price ?? 0,
|
||||
'interval' => $plan->interval ?? 'monthly',
|
||||
'status' => 'active',
|
||||
'starts_at' => now(),
|
||||
'provider' => null,
|
||||
]);
|
||||
|
||||
VerificationService::create($user, 'email');
|
||||
|
||||
return redirect()->route('verify.notice', [
|
||||
'user' => $user->id
|
||||
]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.register')
|
||||
->layout('layouts.blank');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Component;
|
||||
|
||||
class ResetPassword extends Component
|
||||
{
|
||||
public string $token = '';
|
||||
public string $email = '';
|
||||
public string $password = '';
|
||||
public string $password_confirmation = '';
|
||||
public string $error = '';
|
||||
|
||||
public function mount(string $token): void
|
||||
{
|
||||
$this->token = $token;
|
||||
$this->email = request()->query('email', '');
|
||||
}
|
||||
|
||||
public function resetPassword()
|
||||
{
|
||||
$this->validate([
|
||||
'password' => 'required|min:8|confirmed',
|
||||
]);
|
||||
|
||||
$record = DB::table('password_reset_tokens')
|
||||
->where('email', $this->email)
|
||||
->first();
|
||||
|
||||
if (!$record || !Hash::check($this->token, $record->token)) {
|
||||
$this->error = 'Ungültiger oder abgelaufener Link.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (Carbon::parse($record->created_at)->diffInMinutes(now()) > 60) {
|
||||
$this->error = 'Link abgelaufen. Bitte neu anfordern.';
|
||||
return;
|
||||
}
|
||||
|
||||
User::where('email', $this->email)
|
||||
->update(['password' => Hash::make($this->password)]);
|
||||
|
||||
DB::table('password_reset_tokens')
|
||||
->where('email', $this->email)
|
||||
->delete();
|
||||
|
||||
session()->flash('success', 'Passwort erfolgreich geändert.');
|
||||
$this->redirect('/login', navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.reset-password')
|
||||
->layout('layouts.blank');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\MailService;
|
||||
use Livewire\Component;
|
||||
|
||||
class Verify extends Component
|
||||
{
|
||||
public $code = ['', '', '', '', '', ''];
|
||||
public $user;
|
||||
public $cooldown = 0;
|
||||
|
||||
public function mount($user)
|
||||
{
|
||||
$this->user = User::findOrFail($user);
|
||||
}
|
||||
|
||||
public function updatedCode()
|
||||
{
|
||||
if (strlen(implode('', $this->code)) === 6) {
|
||||
$this->verify();
|
||||
}
|
||||
}
|
||||
|
||||
public function tick()
|
||||
{
|
||||
if ($this->cooldown > 0) {
|
||||
$this->cooldown--;
|
||||
}
|
||||
}
|
||||
|
||||
public function resend()
|
||||
{
|
||||
$user = $this->user;
|
||||
|
||||
// 🔥 Reset nach 1 Stunde
|
||||
if (
|
||||
$user->verification_resends_reset_at &&
|
||||
now()->gt($user->verification_resends_reset_at)
|
||||
) {
|
||||
$user->update([
|
||||
'verification_resends' => 0,
|
||||
'verification_resends_reset_at' => now()->addHour(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 🔥 Initial setzen (beim ersten Mal)
|
||||
if (!$user->verification_resends_reset_at) {
|
||||
$user->verification_resends_reset_at = now()->addHour();
|
||||
}
|
||||
|
||||
// 🔥 Limit prüfen
|
||||
if ($user->verification_resends >= 5) {
|
||||
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'error',
|
||||
'title' => t('auth.verify.limit_title'),
|
||||
'message' => t('auth.verify.limit_message'),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 Code neu generieren
|
||||
$code = rand(100000, 999999);
|
||||
|
||||
$user->update([
|
||||
'email_verification_code' => $code,
|
||||
'email_verification_expires_at' => now()->addMinutes(10),
|
||||
|
||||
'verification_resends' => $user->verification_resends + 1,
|
||||
'verification_resends_reset_at' => $user->verification_resends_reset_at ?? now()->addHour(),
|
||||
]);
|
||||
|
||||
MailService::queue(
|
||||
$user->email,
|
||||
'auth.verify',
|
||||
[
|
||||
'user' => $user->name,
|
||||
'code' => $code,
|
||||
],
|
||||
$user->locale,
|
||||
null,
|
||||
$user->id,
|
||||
);
|
||||
|
||||
$this->cooldown = 30;
|
||||
}
|
||||
|
||||
|
||||
public function verify()
|
||||
{
|
||||
$code = implode('', $this->code);
|
||||
|
||||
if ($code !== $this->user->email_verification_code) {
|
||||
$this->addError('code', t('auth.verify.invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (now()->gt($this->user->email_verification_expires_at)) {
|
||||
$this->addError('code', t('auth.verify.expired'));
|
||||
return;
|
||||
}
|
||||
|
||||
$this->user->update([
|
||||
'email_verified_at' => now(),
|
||||
'email_verification_code' => null,
|
||||
'email_verification_expires_at' => null,
|
||||
'verification_resends' => 0,
|
||||
'verification_resends_reset_at' => null,
|
||||
]);
|
||||
|
||||
session()->flash('success', t('auth.verify.success'));
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.verify')
|
||||
->layout('layouts.blank');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,422 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Verification;
|
||||
use App\Services\VerificationService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Component;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class VerifyNotice extends Component
|
||||
{
|
||||
public $cooldown = 0;
|
||||
public $user;
|
||||
public $verification;
|
||||
public $blockedUntil;
|
||||
public $cooldownUntil;
|
||||
|
||||
public function mount($user)
|
||||
{
|
||||
$this->user = User::findOrFail($user);
|
||||
|
||||
if ($this->user->email_verified_at) {
|
||||
return redirect()->route('dashboard.index');
|
||||
}
|
||||
|
||||
// 🔥 aktuelle verification holen
|
||||
$this->verification = Verification::where('user_id', $this->user->id)
|
||||
->where('type', 'email')
|
||||
->whereNull('verified_at')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
// 🔥 COOLDOWN (Redis)
|
||||
$key = "verify:cooldown:{$this->user->id}";
|
||||
$expiresAtRaw = Cache::get($key);
|
||||
|
||||
if ($expiresAtRaw) {
|
||||
$expiresAt = Carbon::parse($expiresAtRaw);
|
||||
|
||||
$this->cooldownUntil = $expiresAt
|
||||
->copy()
|
||||
->timezone($this->user->timezone);
|
||||
|
||||
$this->cooldown = max(0, floor(now()->diffInSeconds($expiresAt, false)));
|
||||
}
|
||||
|
||||
// 🔥 BLOCK (aus verification)
|
||||
if (
|
||||
$this->verification &&
|
||||
$this->verification->resends >= 5
|
||||
) {
|
||||
$this->blockedUntil = $this->verification->resends_reset_at
|
||||
? $this->verification->resends_reset_at
|
||||
->copy()
|
||||
->timezone($this->user->timezone)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
public function tick()
|
||||
{
|
||||
if ($this->cooldown > 0) {
|
||||
$this->cooldown--;
|
||||
}
|
||||
}
|
||||
|
||||
public function resend()
|
||||
{
|
||||
if ($this->cooldown > 0) return;
|
||||
|
||||
$verification = $this->verification;
|
||||
|
||||
// 🔥 wenn keine existiert → neu erstellen
|
||||
if (!$verification) {
|
||||
$this->verification = VerificationService::create($this->user);
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 RESET nach 1h
|
||||
if (
|
||||
$verification->resends_reset_at &&
|
||||
now()->gt($verification->resends_reset_at)
|
||||
) {
|
||||
$verification->update([
|
||||
'resends' => 0,
|
||||
'resends_reset_at' => now()->addHour(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 🔥 INIT
|
||||
if (!$verification->resends_reset_at) {
|
||||
$verification->update([
|
||||
'resends_reset_at' => now()->addHour(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 🔥 LIMIT
|
||||
if ($verification->resends >= 5) {
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'error',
|
||||
'title' => t('auth.verify.limit_title'),
|
||||
'message' => t('auth.verify.limit_message'),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 NEUE VERIFICATION (inkl. Mail + URL)
|
||||
$this->verification = VerificationService::create($this->user);
|
||||
|
||||
// 🔥 COUNTER erhöhen (auf alter verification!)
|
||||
$verification->increment('resends');
|
||||
|
||||
// 🔥 COOLDOWN
|
||||
$expiresAt = now()->addSeconds(30);
|
||||
|
||||
Cache::put(
|
||||
"verify:cooldown:{$this->user->id}",
|
||||
$expiresAt->toDateTimeString(),
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
$this->cooldown = 30;
|
||||
$this->cooldownUntil = $expiresAt
|
||||
->copy()
|
||||
->timezone($this->user->timezone);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.verify-notice')
|
||||
->layout('layouts.blank');
|
||||
}
|
||||
}
|
||||
//
|
||||
//namespace App\Livewire\Auth;
|
||||
//
|
||||
//use App\Models\User;
|
||||
//use App\Services\MailService;
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//use Illuminate\Support\Facades\URL;
|
||||
//use Livewire\Component;
|
||||
//use Carbon\Carbon;
|
||||
//
|
||||
//class VerifyNotice extends Component
|
||||
//{
|
||||
// public $cooldown = 0;
|
||||
// public $user;
|
||||
// public $blockedUntil;
|
||||
// public $cooldownUntil;
|
||||
//
|
||||
// public function mount($user)
|
||||
// {
|
||||
// $this->user = User::findOrFail($user);
|
||||
//
|
||||
// if ($this->user->email_verified_at) {
|
||||
// return redirect()->route('dashboard.index');
|
||||
// }
|
||||
//
|
||||
// $key = "verify:cooldown:{$this->user->id}";
|
||||
//
|
||||
// $expiresAtRaw = Cache::get($key);
|
||||
//
|
||||
// if ($expiresAtRaw) {
|
||||
//
|
||||
// // 🔥 STRING → CARBON
|
||||
// $expiresAt = Carbon::parse($expiresAtRaw);
|
||||
//
|
||||
// $this->cooldownUntil = $expiresAt
|
||||
// ->copy()
|
||||
// ->timezone($this->user->timezone);
|
||||
//
|
||||
// $this->cooldown = max(0, floor(now()->diffInSeconds($expiresAt, false)));
|
||||
// }
|
||||
//
|
||||
// // 🔥 BLOCKED
|
||||
// if ($this->user->verification_resends >= 5) {
|
||||
// $this->blockedUntil = $this->user->verification_resends_reset_at
|
||||
// ? $this->user->verification_resends_reset_at
|
||||
// ->copy()
|
||||
// ->timezone($this->user->timezone)
|
||||
// : null;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public function tick()
|
||||
// {
|
||||
// if ($this->cooldown > 0) {
|
||||
// $this->cooldown--;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public function resend()
|
||||
// {
|
||||
// if ($this->cooldown > 0) return;
|
||||
//
|
||||
// $user = $this->user;
|
||||
// $key = "verify:cooldown:{$user->id}";
|
||||
//
|
||||
// // 🔥 RESET (1h)
|
||||
// if (
|
||||
// $user->verification_resends_reset_at &&
|
||||
// now()->gt($user->verification_resends_reset_at)
|
||||
// ) {
|
||||
// $user->update([
|
||||
// 'verification_resends' => 0,
|
||||
// 'verification_resends_reset_at' => now()->addHour(),
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// // 🔥 INIT
|
||||
// if (!$user->verification_resends_reset_at) {
|
||||
// $user->update([
|
||||
// 'verification_resends_reset_at' => now()->addHour()
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// // 🔥 LIMIT
|
||||
// if ($user->verification_resends >= 5) {
|
||||
// $this->dispatch('notify', [
|
||||
// 'type' => 'error',
|
||||
// 'title' => t('auth.verify.limit_title'),
|
||||
// 'message' => t('auth.verify.limit_message'),
|
||||
// ]);
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // 🔥 SIGNED URL
|
||||
// $url = URL::temporarySignedRoute(
|
||||
// 'verify.user',
|
||||
// now()->addMinutes(10),
|
||||
// ['user' => $user->id]
|
||||
// );
|
||||
//
|
||||
// // 🔥 MAIL
|
||||
// MailService::queue(
|
||||
// $user->email,
|
||||
// 'auth.verify',
|
||||
// [
|
||||
// 'user' => $user->name,
|
||||
// 'url' => $url,
|
||||
// ],
|
||||
// $user->locale,
|
||||
// null,
|
||||
// $user->id
|
||||
// );
|
||||
//
|
||||
// // 🔥 COUNTER
|
||||
// $user->increment('verification_resends');
|
||||
//
|
||||
// // 🔥 COOLDOWN
|
||||
// $expiresAt = now()->addSeconds(30);
|
||||
//
|
||||
// // 👉 STRING speichern!
|
||||
// Cache::put($key, $expiresAt->toDateTimeString(), $expiresAt);
|
||||
//
|
||||
// $this->cooldown = 30;
|
||||
//
|
||||
// $this->cooldownUntil = $expiresAt
|
||||
// ->copy()
|
||||
// ->timezone($user->timezone);
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.auth.verify-notice')
|
||||
// ->layout('layouts.blank');
|
||||
// }
|
||||
//}
|
||||
|
||||
//
|
||||
//namespace App\Livewire\Auth;
|
||||
//
|
||||
//use App\Models\User;
|
||||
//use App\Services\MailService;
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//use Illuminate\Support\Facades\URL;
|
||||
//use Livewire\Component;
|
||||
//
|
||||
//class VerifyNotice extends Component
|
||||
//{
|
||||
// public $cooldown = 0;
|
||||
// public $user;
|
||||
// public $blockedUntil;
|
||||
// public $cooldownUntil;
|
||||
//
|
||||
// public function mount($user)
|
||||
// {
|
||||
// $this->user = User::findOrFail($user);
|
||||
//
|
||||
// $key = "verify:cooldown:{$this->user->id}";
|
||||
//
|
||||
// if (Cache::has($key)) {
|
||||
// $expiresAt = \Carbon\Carbon::parse(Cache::get($key));
|
||||
//
|
||||
// $this->cooldownUntil = $expiresAt
|
||||
// ->copy()
|
||||
// ->timezone($this->user->timezone);
|
||||
//
|
||||
// $this->cooldown = now()->diffInSeconds($expiresAt, false);
|
||||
//
|
||||
// if ($this->cooldown < 0) {
|
||||
// $this->cooldown = 0;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if ($this->user->verification_resends >= 5) {
|
||||
// $this->blockedUntil = $this->user->verification_resends_reset_at
|
||||
// ? \Carbon\Carbon::parse($this->user->verification_resends_reset_at)
|
||||
// ->timezone($this->user->timezone)
|
||||
// : null;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public function tick()
|
||||
// {
|
||||
// if ($this->cooldown > 0) {
|
||||
// $this->cooldown--;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public function resend()
|
||||
// {
|
||||
// if ($this->cooldown > 0) return;
|
||||
//
|
||||
// $user = $this->user;
|
||||
// $key = "verify:cooldown:{$user->id}";
|
||||
//
|
||||
// // 🔥 Reset nach 1 Stunde
|
||||
// if (
|
||||
// $user->verification_resends_reset_at &&
|
||||
// now()->gt($user->verification_resends_reset_at)
|
||||
// ) {
|
||||
// $user->update([
|
||||
// 'verification_resends' => 0,
|
||||
// 'verification_resends_reset_at' => now()->addHour(),
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// // 🔥 Initial setzen
|
||||
// if (!$user->verification_resends_reset_at) {
|
||||
// $user->update([
|
||||
// 'verification_resends_reset_at' => now()->addHour()
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// // 🔥 LIMIT
|
||||
// if ($user->verification_resends >= 5) {
|
||||
// $this->dispatch('notify', [
|
||||
// 'type' => 'error',
|
||||
// 'title' => t('auth.verify.limit_title'),
|
||||
// 'message' => t('auth.verify.limit_message'),
|
||||
// ]);
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // 🔥 URL generieren
|
||||
// $url = URL::temporarySignedRoute(
|
||||
// 'verify.user',
|
||||
// now()->addMinutes(10),
|
||||
// ['user' => $user->id]
|
||||
// );
|
||||
//
|
||||
// // 🔥 Queue
|
||||
// MailService::queue(
|
||||
// $user->email,
|
||||
// 'auth.verify',
|
||||
// [
|
||||
// 'user' => $user->name,
|
||||
// 'url' => $url,
|
||||
// ],
|
||||
// $user->locale,
|
||||
// null,
|
||||
// $user->id
|
||||
// );
|
||||
//
|
||||
// // 🔥 Counter erhöhen
|
||||
// $user->increment('verification_resends');
|
||||
//
|
||||
// $expiresAt = now()->addSeconds(30);
|
||||
//
|
||||
// Cache::put($key, now()->addSeconds(30), now()->addSeconds(30));
|
||||
//
|
||||
// $this->cooldown = 30;
|
||||
// $this->cooldownUntil = $expiresAt;
|
||||
// }
|
||||
//
|
||||
//
|
||||
//// public function resend()
|
||||
//// {
|
||||
//// if ($this->cooldown > 0) return;
|
||||
////
|
||||
//// $user = $this->user;
|
||||
////
|
||||
//// $url = URL::temporarySignedRoute(
|
||||
//// 'verify.user',
|
||||
//// now()->addMinutes(10),
|
||||
//// ['user' => $user->id]
|
||||
//// );
|
||||
////
|
||||
//// MailService::queue(
|
||||
//// $user->email,
|
||||
//// 'auth.verify',
|
||||
//// [
|
||||
//// 'user' => $user->name,
|
||||
//// 'url' => $url,
|
||||
//// ],
|
||||
//// $user->locale,
|
||||
//// null,
|
||||
//// $user->id
|
||||
//// );
|
||||
////
|
||||
//// $this->cooldown = 30;
|
||||
//// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.auth.verify-notice')
|
||||
// ->layout('layouts.blank');
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Automation;
|
||||
|
||||
use App\Models\Automation;
|
||||
use Livewire\Component;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
public bool $panelOpen = false;
|
||||
public string $activeType = '';
|
||||
|
||||
public string $f_minutes_before = '30';
|
||||
public string $f_send_time = '08:00';
|
||||
public string $f_send_day = '1';
|
||||
public string $f_delay_minutes = '30';
|
||||
public string $f_days_before = '7';
|
||||
public string $f_days_inactive = '3';
|
||||
public string $f_days_since_contact = '14';
|
||||
public string $f_min_slot_minutes = '60';
|
||||
|
||||
public function openPanel(string $type): void
|
||||
{
|
||||
$types = config('automations.types');
|
||||
$this->activeType = $type;
|
||||
$automation = $this->getRecord($type);
|
||||
$cfg = $automation?->config ?? $types[$type]['defaults'] ?? [];
|
||||
|
||||
$this->f_minutes_before = (string) ($cfg['minutes_before'] ?? 30);
|
||||
$this->f_send_time = $cfg['send_time'] ?? '08:00';
|
||||
$this->f_send_day = (string) ($cfg['send_day'] ?? 1);
|
||||
$this->f_delay_minutes = (string) ($cfg['delay_minutes'] ?? 30);
|
||||
$this->f_days_before = (string) ($cfg['days_before'] ?? 7);
|
||||
$this->f_days_inactive = (string) ($cfg['days_inactive'] ?? 3);
|
||||
$this->f_days_since_contact = (string) ($cfg['days_since_contact'] ?? 14);
|
||||
$this->f_min_slot_minutes = (string) ($cfg['min_slot_minutes'] ?? 60);
|
||||
|
||||
$this->panelOpen = true;
|
||||
}
|
||||
|
||||
public function closePanel(): void
|
||||
{
|
||||
$this->panelOpen = false;
|
||||
$this->activeType = '';
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$types = config('automations.types');
|
||||
$type = $this->activeType;
|
||||
if (!isset($types[$type])) return;
|
||||
|
||||
$user = auth()->user();
|
||||
$def = $types[$type];
|
||||
$isPro = $user->isInternalUser() || $user->hasFeature('automations');
|
||||
$isFreeType = (bool) ($def['is_free'] ?? false);
|
||||
|
||||
if (!$isFreeType && !$isPro) {
|
||||
$this->closePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
Automation::updateOrCreate(
|
||||
['user_id' => $user->id, 'type' => $type],
|
||||
[
|
||||
'name' => $def['name'],
|
||||
'active' => true,
|
||||
'config' => $this->buildConfig($type),
|
||||
]
|
||||
);
|
||||
|
||||
$this->closePanel();
|
||||
}
|
||||
|
||||
public function toggle(string $type): void
|
||||
{
|
||||
$types = config('automations.types');
|
||||
if (!isset($types[$type])) return;
|
||||
|
||||
$user = auth()->user();
|
||||
$def = $types[$type];
|
||||
$isPro = $user->isInternalUser() || $user->hasFeature('automations');
|
||||
$isFreeType = (bool) ($def['is_free'] ?? false);
|
||||
|
||||
if (!$isFreeType && !$isPro) return;
|
||||
|
||||
$record = $this->getRecord($type);
|
||||
|
||||
if ($record) {
|
||||
$record->update(['active' => !$record->active]);
|
||||
} else {
|
||||
Automation::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => $type,
|
||||
'name' => $def['name'],
|
||||
'active' => true,
|
||||
'config' => $def['defaults'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(string $type): void
|
||||
{
|
||||
Automation::where('user_id', auth()->id())
|
||||
->where('type', $type)
|
||||
->delete();
|
||||
}
|
||||
|
||||
private function getRecord(string $type): ?Automation
|
||||
{
|
||||
return Automation::where('user_id', auth()->id())
|
||||
->where('type', $type)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function buildConfig(string $type): array
|
||||
{
|
||||
return match ($type) {
|
||||
'event_reminder' => [
|
||||
'minutes_before' => (int) $this->f_minutes_before,
|
||||
],
|
||||
'daily_agenda' => [
|
||||
'send_time' => $this->f_send_time,
|
||||
'weekdays_only' => false,
|
||||
],
|
||||
'weekly_overview' => [
|
||||
'send_day' => (int) $this->f_send_day,
|
||||
'send_time' => $this->f_send_time,
|
||||
],
|
||||
'event_followup' => [
|
||||
'delay_minutes' => (int) $this->f_delay_minutes,
|
||||
],
|
||||
'birthday_reminder' => [
|
||||
'days_before' => (int) $this->f_days_before,
|
||||
],
|
||||
'no_activity_reminder' => [
|
||||
'days_inactive' => (int) $this->f_days_inactive,
|
||||
],
|
||||
'daily_summary' => [
|
||||
'send_time' => $this->f_send_time,
|
||||
],
|
||||
'contact_followup' => [
|
||||
'days_since_contact' => (int) $this->f_days_since_contact,
|
||||
],
|
||||
'free_slots_report' => [
|
||||
'send_day' => (int) $this->f_send_day,
|
||||
'send_time' => $this->f_send_time,
|
||||
'min_slot_minutes' => (int) $this->f_min_slot_minutes,
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$user = auth()->user();
|
||||
$isPro = $user->isInternalUser() || $user->hasFeature('automations');
|
||||
$records = Automation::where('user_id', $user->id)
|
||||
->get()
|
||||
->keyBy('type');
|
||||
|
||||
return view('livewire.automation.index', [
|
||||
'types' => config('automations.types'),
|
||||
'records' => $records,
|
||||
'isPro' => $isPro,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Calendar\Forms;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Models\Event;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonPeriod;
|
||||
|
||||
class EventForm extends Component
|
||||
{
|
||||
public $eventId;
|
||||
|
||||
public $title;
|
||||
public $starts_at;
|
||||
|
||||
public $start_time;
|
||||
public $end_time;
|
||||
|
||||
public $is_all_day = false;
|
||||
public $is_multi_day = false;
|
||||
|
||||
public $multi_start_date;
|
||||
public $multi_end_date;
|
||||
|
||||
public $exceptions = [];
|
||||
|
||||
public $color = '#6366f1';
|
||||
|
||||
public array $reminders = [];
|
||||
|
||||
public string $customReminderTime = '';
|
||||
public string $customReminderType = 'time_of_day';
|
||||
|
||||
public $attendeeSearch = '';
|
||||
public $attendeeSuggestions = [];
|
||||
public $attendees = [];
|
||||
|
||||
public $notes;
|
||||
|
||||
public $recurrence = null;
|
||||
public $recurrence_end_date = null;
|
||||
|
||||
protected $listeners = [
|
||||
'eventForm:save' => 'save',
|
||||
'eventForm:delete' => 'delete',
|
||||
];
|
||||
|
||||
public function mount($eventId = null, $starts_at = null, $time = null)
|
||||
{
|
||||
$this->eventId = $eventId;
|
||||
$this->starts_at = $starts_at;
|
||||
|
||||
$tz = auth()->user()->timezone ?? config('app.timezone');
|
||||
|
||||
if (!$eventId && $time) {
|
||||
$this->start_time = $time;
|
||||
[$h, $m] = array_map('intval', explode(':', $time));
|
||||
$endCarbon = Carbon::createFromTime($h, $m)->addHour();
|
||||
$this->end_time = $endCarbon->format('H:i');
|
||||
}
|
||||
|
||||
if ($eventId) {
|
||||
|
||||
$event = Event::with('contacts')->findOrFail($eventId);
|
||||
|
||||
$this->title = $event->title;
|
||||
|
||||
$activeDate = $starts_at
|
||||
? Carbon::parse($starts_at, $tz)
|
||||
: $event->starts_at->copy()->timezone($tz);
|
||||
|
||||
$this->starts_at = $activeDate->format('Y-m-d');
|
||||
|
||||
$display = $event->getDisplayTimeForDate($this->starts_at, $tz);
|
||||
|
||||
$this->start_time = $display['start']?->format('H:i');
|
||||
$this->end_time = $display['end']?->format('H:i');
|
||||
|
||||
$this->is_all_day = $event->is_all_day;
|
||||
$this->color = $event->color;
|
||||
$this->notes = $event->notes;
|
||||
$this->recurrence = $event->recurrence;
|
||||
$this->recurrence_end_date = $event->recurrence_end_date?->format('Y-m-d');
|
||||
|
||||
$this->reminders = $event->reminders ?? [];
|
||||
|
||||
$this->attendees = $event->contacts->map(fn($c) => [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
])->values()->toArray();
|
||||
|
||||
if ($event->ends_at && !$event->starts_at->isSameDay($event->ends_at)) {
|
||||
$this->is_multi_day = true;
|
||||
|
||||
$this->multi_start_date = $event->starts_at
|
||||
->timezone($tz)
|
||||
->format('Y-m-d');
|
||||
|
||||
$this->multi_end_date = $event->ends_at
|
||||
->timezone($tz)
|
||||
->format('Y-m-d');
|
||||
}
|
||||
|
||||
$this->exceptions = collect($event->exceptions ?? [])
|
||||
->keyBy('date')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
public function getDaysProperty()
|
||||
{
|
||||
if (!$this->is_multi_day) return collect();
|
||||
|
||||
return collect(
|
||||
CarbonPeriod::create($this->multi_start_date, $this->multi_end_date)
|
||||
);
|
||||
}
|
||||
|
||||
public function updatedIsMultiDay($value)
|
||||
{
|
||||
if ($value && $this->starts_at) {
|
||||
$this->multi_start_date = $this->starts_at;
|
||||
$this->multi_end_date = Carbon::parse($this->starts_at)->addDay()->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedAttendeeSearch($value)
|
||||
{
|
||||
if (strlen(trim($value)) < 1) {
|
||||
$this->attendeeSuggestions = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$selectedIds = array_column($this->attendees, 'id');
|
||||
|
||||
$this->attendeeSuggestions = auth()->user()
|
||||
->contacts()
|
||||
->where(function ($q) use ($value) {
|
||||
$q->where('name', 'like', "%{$value}%")
|
||||
->orWhere('email', 'like', "%{$value}%");
|
||||
})
|
||||
->whereNotIn('id', $selectedIds)
|
||||
->limit(6)
|
||||
->get(['id', 'name'])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function addAttendee(string $id, string $name): void
|
||||
{
|
||||
$alreadyAdded = collect($this->attendees)->contains('id', $id);
|
||||
if (!$alreadyAdded) {
|
||||
$this->attendees[] = ['id' => $id, 'name' => $name];
|
||||
}
|
||||
$this->attendeeSearch = '';
|
||||
$this->attendeeSuggestions = [];
|
||||
}
|
||||
|
||||
public function removeAttendee(string $id): void
|
||||
{
|
||||
$this->attendees = array_values(
|
||||
array_filter($this->attendees, fn($a) => $a['id'] !== $id)
|
||||
);
|
||||
}
|
||||
|
||||
public function addCustomReminder(): void
|
||||
{
|
||||
if (empty($this->customReminderTime)) return;
|
||||
|
||||
$this->reminders[] = [
|
||||
'type' => $this->customReminderType ?: 'time_of_day',
|
||||
'time' => $this->customReminderTime,
|
||||
];
|
||||
|
||||
$this->customReminderTime = '';
|
||||
$this->customReminderType = 'time_of_day';
|
||||
}
|
||||
|
||||
public function addReminder(string $type, ?int $minutes = null, ?string $time = null): void
|
||||
{
|
||||
$this->reminders[] = array_filter([
|
||||
'type' => $type,
|
||||
'minutes' => $minutes,
|
||||
'time' => $time,
|
||||
], fn($v) => $v !== null);
|
||||
}
|
||||
|
||||
public function removeReminder(int $i): void
|
||||
{
|
||||
array_splice($this->reminders, $i, 1);
|
||||
}
|
||||
|
||||
public function toggleException($date)
|
||||
{
|
||||
if (isset($this->exceptions[$date])) {
|
||||
unset($this->exceptions[$date]);
|
||||
} else {
|
||||
$this->exceptions[$date] = [
|
||||
'start' => $this->start_time,
|
||||
'end' => $this->end_time,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
if (!$this->eventId) return;
|
||||
|
||||
$event = Event::where('id', $this->eventId)
|
||||
->where('user_id', auth()->id())
|
||||
->first();
|
||||
|
||||
if ($event) {
|
||||
$event->contacts()->detach();
|
||||
$event->users()->detach();
|
||||
$event->delete();
|
||||
}
|
||||
|
||||
$this->dispatch('eventCreated');
|
||||
$this->dispatch('sidebar:close');
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$tz = auth()->user()->timezone ?? 'UTC';
|
||||
|
||||
$start = Carbon::createFromFormat(
|
||||
'Y-m-d H:i',
|
||||
$this->starts_at . ' ' . ($this->start_time ?? '00:00'),
|
||||
$tz
|
||||
)->utc();
|
||||
|
||||
$end = $this->end_time
|
||||
? Carbon::createFromFormat('Y-m-d H:i', $this->starts_at . ' ' . $this->end_time, $tz)->utc()
|
||||
: ($this->is_all_day
|
||||
? Carbon::createFromFormat('Y-m-d', $this->starts_at, $tz)->endOfDay()->utc()
|
||||
: null);
|
||||
|
||||
if ($this->is_multi_day) {
|
||||
|
||||
$start = Carbon::createFromFormat(
|
||||
'Y-m-d H:i',
|
||||
$this->multi_start_date . ' ' . ($this->start_time ?? '00:00'),
|
||||
$tz
|
||||
)->utc();
|
||||
|
||||
$end = $this->multi_end_date
|
||||
? Carbon::createFromFormat(
|
||||
'Y-m-d H:i',
|
||||
$this->multi_end_date . ' ' . ($this->end_time ?? '23:59'),
|
||||
$tz
|
||||
)->utc()
|
||||
: null;
|
||||
}
|
||||
|
||||
$exceptions = collect($this->exceptions)
|
||||
->map(fn($e, $date) => [
|
||||
'date' => $date,
|
||||
'start' => $e['start'] ?? null,
|
||||
'end' => $e['end'] ?? null,
|
||||
])
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
$event = Event::updateOrCreate(
|
||||
['id' => $this->eventId],
|
||||
[
|
||||
'user_id' => auth()->id(),
|
||||
'title' => $this->title,
|
||||
'starts_at' => $start,
|
||||
'ends_at' => $end,
|
||||
'is_all_day' => $this->is_all_day,
|
||||
'color' => $this->color,
|
||||
'notes' => $this->notes,
|
||||
'exceptions' => $exceptions,
|
||||
'reminders' => $this->reminders,
|
||||
'recurrence' => $this->recurrence ?: null,
|
||||
'recurrence_end_date' => $this->recurrence ? ($this->recurrence_end_date ?: null) : null,
|
||||
]
|
||||
);
|
||||
|
||||
$attendeeIds = array_column($this->attendees, 'id');
|
||||
$event->contacts()->sync($attendeeIds);
|
||||
|
||||
$this->dispatch('eventCreated');
|
||||
$this->dispatch('sidebar:close');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.calendar.forms.event-form');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,894 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Livewire\Calendar;
|
||||
|
||||
use App\Models\Event;
|
||||
use Livewire\Component;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
public $view = 'month';
|
||||
public $date;
|
||||
public $events = [];
|
||||
public $search = '';
|
||||
public $from = null;
|
||||
public $to = null;
|
||||
|
||||
public $rawEvents = [];
|
||||
public $holidays = [
|
||||
'2026-04-06' => 'Ostermontag',
|
||||
];
|
||||
|
||||
protected $queryString = [
|
||||
'view' => ['except' => 'month'],
|
||||
'date' => ['except' => ''],
|
||||
'search' => ['except' => ''],
|
||||
'from' => ['except' => ''],
|
||||
'to' => ['except' => ''],
|
||||
];
|
||||
|
||||
protected $listeners = ['eventCreated' => 'loadEvents'];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$tz = auth()->user()->timezone;
|
||||
|
||||
$this->date = $this->date
|
||||
? Carbon::parse($this->date, $tz)
|
||||
: now($tz);
|
||||
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
public function updatedView()
|
||||
{
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
public function setView($view)
|
||||
{
|
||||
$this->view = $view;
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
public function loadEvents()
|
||||
{
|
||||
$tz = auth()->user()->timezone;
|
||||
$user = auth()->user();
|
||||
|
||||
// 🔥 VIEW RANGE
|
||||
if ($this->view === 'month') {
|
||||
$start = $this->date->copy()->startOfMonth()->startOfWeek();
|
||||
$end = $this->date->copy()->endOfMonth()->endOfWeek();
|
||||
} elseif ($this->view === 'week') {
|
||||
$start = $this->date->copy()->startOfWeek();
|
||||
$end = $this->date->copy()->endOfWeek();
|
||||
} else {
|
||||
$start = $this->date->copy()->startOfDay();
|
||||
$end = $this->date->copy()->endOfDay();
|
||||
}
|
||||
|
||||
$query = $user->events();
|
||||
|
||||
// 🔥 SEARCH
|
||||
if ($this->search) {
|
||||
$query->where('title', 'like', '%' . $this->search . '%');
|
||||
}
|
||||
|
||||
// 🔥 FILTER (richtig für Multi-Day!)
|
||||
if ($this->from) {
|
||||
$from = Carbon::parse($this->from)->startOfDay();
|
||||
|
||||
$query->where(function ($q) use ($from) {
|
||||
$q->where('starts_at', '>=', $from)
|
||||
->orWhere('ends_at', '>=', $from);
|
||||
});
|
||||
}
|
||||
|
||||
if ($this->to) {
|
||||
$to = Carbon::parse($this->to)->endOfDay();
|
||||
|
||||
$query->where(function ($q) use ($to) {
|
||||
$q->where('starts_at', '<=', $to)
|
||||
->orWhere('ends_at', '<=', $to);
|
||||
});
|
||||
}
|
||||
|
||||
// 🔥 RANGE (WICHTIG)
|
||||
$rawEvents = $query->where(function ($q) use ($start, $end) {
|
||||
$q->whereBetween('starts_at', [$start, $end])
|
||||
->orWhereBetween('ends_at', [$start, $end])
|
||||
->orWhere(function ($q2) use ($start, $end) {
|
||||
$q2->where('starts_at', '<=', $start)
|
||||
->where('ends_at', '>=', $end);
|
||||
});
|
||||
})->get();
|
||||
|
||||
$this->rawEvents = $rawEvents;
|
||||
// 🔥 SPLIT EVENTS IN DAYS
|
||||
$events = [];
|
||||
|
||||
foreach ($rawEvents as $event) {
|
||||
|
||||
$startDate = $event->starts_at
|
||||
->copy()
|
||||
->setTimezone($tz)
|
||||
->startOfDay();
|
||||
|
||||
$endDate = $event->ends_at
|
||||
? $event->ends_at->copy()->setTimezone($tz)->startOfDay()
|
||||
: $startDate;
|
||||
|
||||
for ($date = $startDate->copy(); $date <= $endDate; $date->addDay()) {
|
||||
$events[$date->format('Y-m-d')][] = $event;
|
||||
}
|
||||
}
|
||||
|
||||
$this->events = collect($events)->map(fn($g) => collect($g));
|
||||
}
|
||||
|
||||
public function updatedSearch()
|
||||
{
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
public function updatedFrom()
|
||||
{
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
public function updatedTo()
|
||||
{
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
public function updateEventTime($eventId, $start, $end)
|
||||
{
|
||||
$event = \App\Models\Event::findOrFail($eventId);
|
||||
|
||||
$tz = auth()->user()->timezone ?? 'UTC';
|
||||
|
||||
$event->update([
|
||||
'starts_at' => Carbon::parse($start, $tz)->utc(),
|
||||
'ends_at' => $end ? Carbon::parse($end, $tz)->utc() : null,
|
||||
]);
|
||||
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
public function moveEventToDate($eventId, $date)
|
||||
{
|
||||
$event = \App\Models\Event::findOrFail($eventId);
|
||||
|
||||
$tz = auth()->user()->timezone ?? 'UTC';
|
||||
|
||||
$start = $event->starts_at->copy()->timezone($tz);
|
||||
$end = $event->ends_at?->copy()->timezone($tz);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 🔥 FALL 1: ALL DAY ODER MULTI DAY
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
$isMultiDay = $end && $start->toDateString() !== $end->toDateString();
|
||||
|
||||
if ($event->is_all_day || $isMultiDay) {
|
||||
|
||||
// 👉 Tage-Differenz behalten (NICHT Sekunden!)
|
||||
$daySpan = $end
|
||||
? $start->startOfDay()->diffInDays($end->startOfDay())
|
||||
: 0;
|
||||
|
||||
// 👉 neuer Start (immer 00:00 bei allday)
|
||||
$newStart = Carbon::parse($date, $tz)->startOfDay();
|
||||
|
||||
// 👉 neuer End
|
||||
$newEnd = $end
|
||||
? $newStart->copy()->addDays($daySpan)->endOfDay()
|
||||
: null;
|
||||
|
||||
} else {
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 🔥 FALL 2: NORMALER TERMIN
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
$duration = $end
|
||||
? $start->diffInSeconds($end)
|
||||
: 3600;
|
||||
|
||||
$newStart = Carbon::parse(
|
||||
$date . ' ' . $start->format('H:i'),
|
||||
$tz
|
||||
);
|
||||
|
||||
$newEnd = $end
|
||||
? $newStart->copy()->addSeconds($duration)
|
||||
: null;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| SAVE (UTC!)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
$event->update([
|
||||
'starts_at' => $newStart->utc(),
|
||||
'ends_at' => $newEnd?->utc(),
|
||||
]);
|
||||
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
// public function moveEventToDate($eventId, $date)
|
||||
// {
|
||||
// $event = \App\Models\Event::findOrFail($eventId);
|
||||
//
|
||||
// $tz = auth()->user()->timezone ?? 'UTC';
|
||||
//
|
||||
// // aktuelle Zeiten holen (in User TZ)
|
||||
// $start = $event->starts_at->copy()->timezone($tz);
|
||||
// $end = $event->ends_at?->copy()->timezone($tz);
|
||||
//
|
||||
// // 🔥 Dauer berechnen (wichtig für Multi-Day)
|
||||
// $duration = $end
|
||||
// ? $start->diffInSeconds($end)
|
||||
// : 3600;
|
||||
//
|
||||
// // 🔥 neues Datum + gleiche Uhrzeit
|
||||
// $newStart = \Carbon\Carbon::parse(
|
||||
// $date . ' ' . $start->format('H:i'),
|
||||
// $tz
|
||||
// )->utc();
|
||||
//
|
||||
// $newEnd = $end
|
||||
// ? $newStart->copy()->addSeconds($duration)
|
||||
// : null;
|
||||
//
|
||||
// $event->update([
|
||||
// 'starts_at' => $newStart,
|
||||
// 'ends_at' => $newEnd,
|
||||
// ]);
|
||||
//
|
||||
// $this->loadEvents();
|
||||
// }
|
||||
|
||||
public function goToToday()
|
||||
{
|
||||
$this->date = now(auth()->user()->timezone);
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
public function next()
|
||||
{
|
||||
if ($this->view === 'month') $this->date->addMonth();
|
||||
if ($this->view === 'week') $this->date->addWeek();
|
||||
if ($this->view === 'day') $this->date->addDay();
|
||||
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
public function prev()
|
||||
{
|
||||
if ($this->view === 'month') $this->date->subMonth();
|
||||
if ($this->view === 'week') $this->date->subWeek();
|
||||
if ($this->view === 'day') $this->date->subDay();
|
||||
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
public function moveEventToDateTime($eventId, $date, $time)
|
||||
{
|
||||
$event = Event::findOrFail($eventId);
|
||||
|
||||
$tz = auth()->user()->timezone ?? config('app.timezone');
|
||||
|
||||
if (!preg_match('/^\d{2}:\d{2}$/', $time)) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$hour, $minute] = explode(':', $time);
|
||||
|
||||
$hour = min(max((int)$hour, 0), 23);
|
||||
$minute = min(max((int)$minute, 0), 59);
|
||||
|
||||
// 🔥 WICHTIG: alte Zeiten in USER TZ holen
|
||||
$oldStart = $event->starts_at->copy()->setTimezone($tz);
|
||||
$oldEnd = $event->ends_at?->copy()->setTimezone($tz);
|
||||
|
||||
$duration = $oldEnd
|
||||
? $oldStart->diffInMinutes($oldEnd)
|
||||
: 60;
|
||||
|
||||
if ($duration <= 0) {
|
||||
$duration = 60;
|
||||
}
|
||||
|
||||
// 🔥 neue Zeit in USER TZ setzen
|
||||
$newStart = Carbon::parse($date, $tz)->setTime($hour, $minute);
|
||||
$newEnd = $newStart->copy()->addMinutes($duration);
|
||||
|
||||
// 🔥 zurück in UTC speichern
|
||||
$event->starts_at = $newStart->copy()->utc();
|
||||
$event->ends_at = $newEnd->copy()->utc();
|
||||
|
||||
$event->save();
|
||||
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
|
||||
public function updateEventTimeAndDate($eventId, $date, $start, $end)
|
||||
{
|
||||
|
||||
$event = Event::findOrFail($eventId);
|
||||
|
||||
$tz = auth()->user()->timezone ?? config('app.timezone');
|
||||
|
||||
$start = Carbon::parse($date.' '.$start, $tz)->utc();
|
||||
$end = Carbon::parse($date.' '.$end, $tz)->utc();
|
||||
|
||||
if ($end->lessThanOrEqualTo($start)) {
|
||||
$end = $start->copy()->addHour();
|
||||
}
|
||||
|
||||
$event->update([
|
||||
'starts_at' => $start,
|
||||
'ends_at' => $end,
|
||||
]);
|
||||
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
|
||||
public function updateEventRange($id, $startDate, $endDate, $startTime, $endTime)
|
||||
{
|
||||
$event = Event::findOrFail($id);
|
||||
|
||||
$tz = auth()->user()->timezone;
|
||||
|
||||
$start = Carbon::parse("$startDate $startTime", $tz)->utc();
|
||||
$end = Carbon::parse("$endDate $endTime", $tz)->utc();
|
||||
|
||||
if ($end->lessThanOrEqualTo($start)) {
|
||||
$end = $start->copy()->addHour();
|
||||
}
|
||||
|
||||
$event->update([
|
||||
'starts_at' => $start,
|
||||
'ends_at' => $end,
|
||||
]);
|
||||
|
||||
$this->loadEvents();
|
||||
}
|
||||
|
||||
|
||||
private function transformAllDayEvents($events)
|
||||
{
|
||||
return $events->map(function ($event) {
|
||||
|
||||
$start = $event->starts_at->copy();
|
||||
$end = $event->ends_at?->copy() ?? $start;
|
||||
|
||||
$startDay = $start->startOfDay();
|
||||
$endDay = $end->startOfDay();
|
||||
|
||||
return [
|
||||
'id' => $event->id,
|
||||
'title' => $event->title,
|
||||
'color' => $event->color,
|
||||
|
||||
'start_day' => $startDay,
|
||||
'end_day' => $endDay,
|
||||
|
||||
'duration_days' => $startDay->diffInDays($endDay) + 1,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function mapAllDayToWeek($events, $weekStart)
|
||||
{
|
||||
return $events->map(function ($event) use ($weekStart) {
|
||||
|
||||
$startOffset = $event['start_day']->diffInDays($weekStart, false);
|
||||
$endOffset = $event['end_day']->diffInDays($weekStart, false);
|
||||
|
||||
// clamp auf Woche
|
||||
$start = max($startOffset, 0);
|
||||
$end = min($endOffset, 6);
|
||||
|
||||
$span = ($end - $start) + 1;
|
||||
|
||||
return [
|
||||
...$event,
|
||||
'left' => ($start / 7) * 100,
|
||||
'width' => ($span / 7) * 100,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function getCalendarDaysProperty(): \Illuminate\Support\Collection
|
||||
{
|
||||
$tz = auth()->user()->timezone;
|
||||
|
||||
return collect(range(0, 6))->map(function ($i) use ($tz) {
|
||||
|
||||
$day = $this->date->copy()->startOfWeek()->addDays($i);
|
||||
$key = $day->format('Y-m-d');
|
||||
|
||||
$events = collect($this->events[$key] ?? []);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 🔥 ALLDAY + MULTIDAY RAUSFILTERN (WICHTIG!)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
$filtered = $events->reject(function ($event) use ($tz) {
|
||||
|
||||
$start = $event->starts_at->copy()->setTimezone($tz);
|
||||
$end = $event->ends_at?->copy()->setTimezone($tz) ?? $start;
|
||||
|
||||
// 👉 gleiche Logik wie in getAllDayEventsProperty
|
||||
$isMultiDay = $end->startOfDay()->gt($start->startOfDay());
|
||||
|
||||
return $event->is_all_day || $isMultiDay;
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 🔥 TIMED EVENTS TRANSFORMIEREN
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
$mapped = $filtered
|
||||
->map(fn($event) => $this->transformEvent($event, $day, $tz))
|
||||
->sortBy('start')
|
||||
->values();
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 🔥 OVERLAP FIX
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
$timed = $mapped->map(function ($event) use ($mapped) {
|
||||
|
||||
$overlapping = $mapped->filter(function ($e) use ($event) {
|
||||
return $e['start'] < $event['end']
|
||||
&& $e['end'] > $event['start'];
|
||||
})->values();
|
||||
|
||||
$count = max($overlapping->count(), 1);
|
||||
|
||||
$index = $overlapping->pluck('id')->search($event['id']);
|
||||
$index = $index === false ? 0 : $index;
|
||||
|
||||
$event['count'] = $count;
|
||||
$event['index'] = $index;
|
||||
|
||||
return $event;
|
||||
});
|
||||
|
||||
return [
|
||||
'date' => $day,
|
||||
'key' => $key,
|
||||
'timed' => $timed,
|
||||
];
|
||||
});
|
||||
}
|
||||
// public function getCalendarDaysProperty(): \Illuminate\Support\Collection
|
||||
// {
|
||||
// $tz = auth()->user()->timezone;
|
||||
//
|
||||
// return collect(range(0, 6))->map(function ($i) use ($tz) {
|
||||
//
|
||||
// $day = $this->date->copy()->startOfWeek()->addDays($i);
|
||||
// $key = $day->format('Y-m-d');
|
||||
//
|
||||
// $events = collect($this->events[$key] ?? []);
|
||||
//
|
||||
// /*
|
||||
// |--------------------------------------------------------------------------
|
||||
// | 🔥 NUR ALLDAY RAUS
|
||||
// |--------------------------------------------------------------------------
|
||||
// */
|
||||
// $mapped = $events
|
||||
// ->reject(fn($e) => $e->is_all_day)
|
||||
// ->map(fn($event) => $this->transformEvent($event, $day, $tz))
|
||||
// ->sortBy('start')
|
||||
// ->values();
|
||||
//
|
||||
// /*
|
||||
// |--------------------------------------------------------------------------
|
||||
// | OVERLAP
|
||||
// |--------------------------------------------------------------------------
|
||||
// */
|
||||
// $timed = $mapped->map(function ($event) use ($mapped) {
|
||||
//
|
||||
// $overlapping = $mapped->filter(function ($e) use ($event) {
|
||||
// return $e['start'] < $event['end']
|
||||
// && $e['end'] > $event['start'];
|
||||
// })->values();
|
||||
//
|
||||
// $count = max($overlapping->count(), 1);
|
||||
//
|
||||
// $index = $overlapping->pluck('id')->search($event['id']);
|
||||
// $index = $index === false ? 0 : $index;
|
||||
//
|
||||
// $event['count'] = $count;
|
||||
// $event['index'] = $index;
|
||||
//
|
||||
// return $event;
|
||||
// });
|
||||
//
|
||||
// return [
|
||||
// 'date' => $day,
|
||||
// 'key' => $key,
|
||||
// 'timed' => $timed,
|
||||
// ];
|
||||
// });
|
||||
// }
|
||||
|
||||
// public function getCalendarDaysProperty(): \Illuminate\Support\Collection
|
||||
// {
|
||||
// $tz = auth()->user()->timezone;
|
||||
//
|
||||
// return collect(range(0, 6))->map(function ($i) use ($tz) {
|
||||
//
|
||||
// $day = $this->date->copy()->startOfWeek()->addDays($i);
|
||||
// $key = $day->format('Y-m-d');
|
||||
//
|
||||
// $events = collect($this->events[$key] ?? []);
|
||||
//
|
||||
// // 👉 AllDay Events
|
||||
// $allDay = $events->filter(fn($e) => $e->is_all_day);
|
||||
//
|
||||
// // 👉 Timed Events transformieren
|
||||
// $mapped = $events
|
||||
// ->reject(fn($e) => $e->is_all_day)
|
||||
// ->map(fn($event) => $this->transformEvent($event, $day, $tz))
|
||||
// ->sortBy('start') // 🔥 wichtig für stabile Reihenfolge
|
||||
// ->values();
|
||||
//
|
||||
// // 👉 Overlap berechnen
|
||||
// $timed = $mapped->map(function ($event) use ($mapped) {
|
||||
//
|
||||
// $overlapping = $mapped->filter(function ($e) use ($event) {
|
||||
// return $e['start'] < $event['end']
|
||||
// && $e['end'] > $event['start'];
|
||||
// })->values();
|
||||
//
|
||||
// $count = max($overlapping->count(), 1);
|
||||
//
|
||||
// $index = $overlapping->pluck('id')->search($event['id']);
|
||||
// $index = $index === false ? 0 : $index;
|
||||
//
|
||||
// $event['count'] = $count;
|
||||
// $event['index'] = $index;
|
||||
//
|
||||
// return $event;
|
||||
// });
|
||||
//
|
||||
// return [
|
||||
// 'date' => $day,
|
||||
// 'key' => $key,
|
||||
// 'allDay' => $allDay,
|
||||
// 'timed' => $timed,
|
||||
// ];
|
||||
// });
|
||||
// }
|
||||
|
||||
|
||||
// public function getAllDayEventsProperty()
|
||||
// {
|
||||
// $tz = auth()->user()->timezone;
|
||||
//
|
||||
// $weekStart = $this->date->copy()->startOfWeek();
|
||||
// $weekEnd = $this->date->copy()->endOfWeek();
|
||||
//
|
||||
// return collect($this->rawEvents)
|
||||
// ->filter(fn($event) => $event->is_all_day == true)
|
||||
// ->map(function ($event) use ($weekStart, $weekEnd, $tz) {
|
||||
//
|
||||
// $start = $event->starts_at->copy()->setTimezone($tz);
|
||||
// $end = $event->ends_at
|
||||
// ? $event->ends_at->copy()->setTimezone($tz)
|
||||
// : $start;
|
||||
//
|
||||
// if ($end->lt($weekStart) || $start->gt($weekEnd)) {
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// $startClamped = $start->copy()->max($weekStart)->startOfDay();
|
||||
// $endClamped = $end->copy()->min($weekEnd)->startOfDay();
|
||||
//
|
||||
// $startOffset = $weekStart->diffInDays($startClamped);
|
||||
// $endOffset = $weekStart->diffInDays($endClamped);
|
||||
//
|
||||
// $span = max(($endOffset - $startOffset) + 1, 1);
|
||||
//
|
||||
// return [
|
||||
// 'id' => $event->id,
|
||||
// 'title' => $event->title,
|
||||
// 'color' => $event->color,
|
||||
//
|
||||
// // 🔥 DAS HAT GEFEHLT
|
||||
// 'start' => $startClamped,
|
||||
// 'end' => $endClamped,
|
||||
//
|
||||
// 'left' => ($startOffset / 7) * 100,
|
||||
// 'width' => ($span / 7) * 100,
|
||||
// ];
|
||||
// })
|
||||
// ->filter()
|
||||
// ->values();
|
||||
// }
|
||||
|
||||
// public function getAllDayEventsProperty()
|
||||
// {
|
||||
// $tz = auth()->user()->timezone;
|
||||
//
|
||||
// $weekStart = $this->date->copy()->startOfWeek()->startOfDay();
|
||||
// $weekEnd = $this->date->copy()->endOfWeek()->endOfDay();
|
||||
//
|
||||
// $events = collect($this->rawEvents)
|
||||
//
|
||||
// ->filter(function ($event) use ($tz) {
|
||||
//
|
||||
// $start = $event->starts_at->copy()->setTimezone($tz);
|
||||
// $end = $event->ends_at?->copy()->setTimezone($tz) ?? $start;
|
||||
//
|
||||
// // 👉 echtes AllDay oder MultiDay
|
||||
// return $event->is_all_day
|
||||
// || $end->startOfDay()->gt($start->startOfDay());
|
||||
// })
|
||||
//
|
||||
// ->map(function ($event) use ($tz, $weekStart, $weekEnd) {
|
||||
//
|
||||
// $start = $event->starts_at->copy()->setTimezone($tz);
|
||||
// $end = $event->ends_at
|
||||
// ? $event->ends_at->copy()->setTimezone($tz)
|
||||
// : $start;
|
||||
//
|
||||
// /*
|
||||
// |--------------------------------------------------------------------------
|
||||
// | ❌ außerhalb → raus
|
||||
// |--------------------------------------------------------------------------
|
||||
// */
|
||||
// if ($end->lt($weekStart) || $start->gt($weekEnd)) {
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// /*
|
||||
// |--------------------------------------------------------------------------
|
||||
// | 🔥 CLAMP AUF WOCHE
|
||||
// |--------------------------------------------------------------------------
|
||||
// */
|
||||
// $startClamped = $start->lt($weekStart)
|
||||
// ? $weekStart->copy()
|
||||
// : $start->copy();
|
||||
//
|
||||
// $endClamped = $end->gt($weekEnd)
|
||||
// ? $weekEnd->copy()
|
||||
// : $end->copy();
|
||||
//
|
||||
// /*
|
||||
// |--------------------------------------------------------------------------
|
||||
// | 🔥 WICHTIG: START/END auf DAY LEVEL
|
||||
// |--------------------------------------------------------------------------
|
||||
// */
|
||||
// $startDay = $startClamped->copy()->startOfDay();
|
||||
// $endDay = $endClamped->copy()->startOfDay();
|
||||
//
|
||||
// /*
|
||||
// |--------------------------------------------------------------------------
|
||||
// | 🔥 OFFSET BERECHNUNG (STABIL)
|
||||
// |--------------------------------------------------------------------------
|
||||
// */
|
||||
// $startOffset = $weekStart->diffInDays($startDay);
|
||||
// $endOffset = $weekStart->diffInDays($endDay);
|
||||
//
|
||||
// $span = max(1, ($endOffset - $startOffset) + 1);
|
||||
//
|
||||
// return [
|
||||
// 'id' => $event->id,
|
||||
// 'title' => $event->title,
|
||||
// 'color' => $event->color,
|
||||
//
|
||||
// 'start' => $startDay,
|
||||
// 'end' => $endDay,
|
||||
//
|
||||
// 'left' => ($startOffset / 7) * 100,
|
||||
// 'width' => ($span / 7) * 100,
|
||||
// ];
|
||||
// })
|
||||
//
|
||||
// ->filter()
|
||||
// ->sortBy('start')
|
||||
// ->values();
|
||||
//
|
||||
// return $this->stackAllDayEvents($events);
|
||||
// }
|
||||
|
||||
public function getAllDayEventsProperty()
|
||||
{
|
||||
$tz = auth()->user()->timezone;
|
||||
|
||||
$weekStart = $this->date->copy()->startOfWeek()->startOfDay();
|
||||
$weekEnd = $this->date->copy()->endOfWeek()->endOfDay();
|
||||
|
||||
$events = collect($this->rawEvents)
|
||||
|
||||
->filter(function ($event) use ($tz) {
|
||||
|
||||
$start = $event->starts_at->copy()->setTimezone($tz);
|
||||
$end = $event->ends_at?->copy()->setTimezone($tz) ?? $start;
|
||||
|
||||
$isMultiDay = $end->startOfDay()->gt($start->startOfDay());
|
||||
|
||||
return $event->is_all_day || $isMultiDay;
|
||||
})
|
||||
|
||||
->map(function ($event) use ($tz, $weekStart, $weekEnd) {
|
||||
|
||||
$start = $event->starts_at->copy()->setTimezone($tz)->startOfDay();
|
||||
$end = $event->ends_at
|
||||
? $event->ends_at->copy()->setTimezone($tz)->startOfDay()
|
||||
: $start;
|
||||
|
||||
if ($end->lt($weekStart) || $start->gt($weekEnd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$startClamped = $start->lt($weekStart) ? $weekStart->copy() : $start;
|
||||
$endClamped = $end->gt($weekEnd) ? $weekEnd->copy() : $end;
|
||||
|
||||
$startOffset = $weekStart->diffInDays($startClamped);
|
||||
$endOffset = $weekStart->diffInDays($endClamped);
|
||||
|
||||
$span = ($endOffset - $startOffset) + 1;
|
||||
|
||||
return [
|
||||
'id' => $event->id,
|
||||
'title' => $event->title,
|
||||
'color' => $event->color,
|
||||
|
||||
'start' => $startClamped,
|
||||
'end' => $endClamped,
|
||||
|
||||
'col' => $startOffset,
|
||||
'span' => $span,
|
||||
];
|
||||
})
|
||||
|
||||
->filter()
|
||||
->sortBy('start')
|
||||
->values();
|
||||
|
||||
return $this->stackAllDayEvents($events);
|
||||
}
|
||||
|
||||
private function stackAllDayEvents($events)
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
|
||||
$placed = false;
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
|
||||
$collision = collect($row)->first(function ($e) use ($event) {
|
||||
|
||||
$aStart = $event['col'];
|
||||
$aEnd = $event['col'] + $event['span'] - 1;
|
||||
|
||||
$bStart = $e['col'];
|
||||
$bEnd = $e['col'] + $e['span'] - 1;
|
||||
|
||||
return !($aEnd < $bStart || $aStart > $bEnd);
|
||||
});
|
||||
|
||||
if (!$collision) {
|
||||
$row[] = $event;
|
||||
$placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$placed) {
|
||||
$rows[] = [$event];
|
||||
}
|
||||
}
|
||||
|
||||
return collect($rows)->map(function ($row, $rowIndex) {
|
||||
|
||||
return collect($row)->map(function ($event) use ($rowIndex) {
|
||||
return [
|
||||
...$event,
|
||||
'row' => $rowIndex
|
||||
];
|
||||
});
|
||||
|
||||
})->flatten(1);
|
||||
}
|
||||
|
||||
private function transformEvent($event, $day, $tz)
|
||||
{
|
||||
$start = $event->starts_at->copy()->setTimezone($tz);
|
||||
$end = $event->ends_at?->copy()->setTimezone($tz);
|
||||
|
||||
$dayStart = $day->copy()->startOfDay();
|
||||
$dayEnd = $day->copy()->endOfDay();
|
||||
|
||||
// 👉 Seminar / MultiDay Fix
|
||||
if ($end && $start->diffInDays($end) > 0 && !$event->is_all_day) {
|
||||
|
||||
$effectiveStart = $dayStart->copy()->setTime($start->hour, $start->minute);
|
||||
$effectiveEnd = $dayStart->copy()->setTime($end->hour, $end->minute);
|
||||
|
||||
// 👉 Exceptions
|
||||
$exceptions = is_array($event->exceptions)
|
||||
? $event->exceptions
|
||||
: json_decode($event->exceptions ?? '[]', true);
|
||||
|
||||
$exception = collect($exceptions)
|
||||
->firstWhere('date', $day->format('Y-m-d'));
|
||||
|
||||
if ($exception) {
|
||||
if (!empty($exception['start'])) {
|
||||
[$h, $m] = explode(':', $exception['start']);
|
||||
$effectiveStart->setTime($h, $m);
|
||||
}
|
||||
|
||||
if (!empty($exception['end'])) {
|
||||
[$h, $m] = explode(':', $exception['end']);
|
||||
$effectiveEnd->setTime($h, $m);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
$effectiveStart = $start;
|
||||
$effectiveEnd = $end ?? $start->copy()->addHour();
|
||||
}
|
||||
|
||||
$hourHeight = 64;
|
||||
|
||||
$top = ($effectiveStart->hour * $hourHeight)
|
||||
+ (($effectiveStart->minute / 60) * $hourHeight);
|
||||
|
||||
$height = max($effectiveStart->diffInMinutes($effectiveEnd), 1) / 60 * $hourHeight;
|
||||
|
||||
|
||||
return [
|
||||
'model' => $event,
|
||||
'id' => $event->id,
|
||||
'title' => $event->title,
|
||||
'top' => $top,
|
||||
'height' => $height,
|
||||
'start' => $effectiveStart,
|
||||
'end' => $effectiveEnd,
|
||||
];
|
||||
}
|
||||
|
||||
public function colorToRgba($color, $opacity = 1): string
|
||||
{
|
||||
if (!$color) return 'rgba(99,102,241,0.8)'; // fallback
|
||||
|
||||
$color = ltrim($color, '#');
|
||||
|
||||
$r = hexdec(substr($color, 0, 2));
|
||||
$g = hexdec(substr($color, 2, 2));
|
||||
$b = hexdec(substr($color, 4, 2));
|
||||
|
||||
return "rgba($r, $g, $b, $opacity)";
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.calendar.index')
|
||||
->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Calendar\Modals;
|
||||
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use App\Models\Event;
|
||||
|
||||
class DayEventsModal extends ModalComponent
|
||||
{
|
||||
public $date;
|
||||
public $events = [];
|
||||
|
||||
public function mount($date)
|
||||
{
|
||||
$this->date = $date;
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
$this->events = $user->events()
|
||||
->whereDate('starts_at', $date)
|
||||
->orderBy('starts_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.calendar.modals.day-events-modal');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,823 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Livewire\Calendar\Modals;
|
||||
|
||||
use App\Models\Event;
|
||||
use Carbon\Carbon;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class EventModal extends ModalComponent
|
||||
{
|
||||
public $eventId = null;
|
||||
|
||||
public $date;
|
||||
|
||||
public $title = '';
|
||||
public $color = '#6366f1';
|
||||
|
||||
// 🔥 GLOBAL
|
||||
public $time = '12:00';
|
||||
public $end_time = null;
|
||||
|
||||
// 🔥 EXCEPTION (GETRENNT!)
|
||||
public $exception_time = null;
|
||||
public $exception_end_time = null;
|
||||
|
||||
public $start_date = null;
|
||||
public $end_date = null;
|
||||
public $is_all_day = false;
|
||||
|
||||
public $use_exception = false;
|
||||
|
||||
public function mount($date = null, $eventId = null)
|
||||
{
|
||||
$tz = auth()->user()->timezone;
|
||||
|
||||
$this->eventId = $eventId;
|
||||
|
||||
if ($eventId) {
|
||||
|
||||
$event = Event::findOrFail($eventId);
|
||||
|
||||
$this->title = $event->title;
|
||||
$this->color = $event->color ?? '#6366f1';
|
||||
|
||||
$start = $event->starts_at->timezone($tz);
|
||||
$end = $event->ends_at?->timezone($tz);
|
||||
|
||||
$this->start_date = $start->format('Y-m-d');
|
||||
$this->end_date = $end ? $end->format('Y-m-d') : $this->start_date;
|
||||
|
||||
$this->date = $date ?? $this->start_date;
|
||||
|
||||
// 🔥 GLOBAL ZEIT
|
||||
$this->time = $start->format('H:i');
|
||||
$this->end_time = $end?->format('H:i');
|
||||
|
||||
// 🔥 DEFAULT → exception = global
|
||||
$this->exception_time = $this->time;
|
||||
$this->exception_end_time = $this->end_time;
|
||||
|
||||
// 🔥 EXCEPTIONS CHECK
|
||||
$exceptions = $event->exceptions ?? [];
|
||||
|
||||
if (is_string($exceptions)) {
|
||||
$exceptions = json_decode($exceptions, true);
|
||||
}
|
||||
|
||||
if (is_array($exceptions)) {
|
||||
foreach ($exceptions as $ex) {
|
||||
|
||||
if (($ex['date'] ?? null) === $this->date) {
|
||||
|
||||
$this->use_exception = true;
|
||||
|
||||
$this->exception_time = $ex['start'] ?? $this->time;
|
||||
$this->exception_end_time = $ex['end'] ?? $this->end_time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->is_all_day = $event->is_all_day ?? false;
|
||||
|
||||
if ($this->is_all_day) {
|
||||
$this->time = null;
|
||||
$this->end_time = null;
|
||||
$this->exception_time = null;
|
||||
$this->exception_end_time = null;
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
$this->date = $date ?? now($tz)->format('Y-m-d');
|
||||
$this->start_date = $this->date;
|
||||
$this->end_date = $this->date;
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedUseException($value)
|
||||
{
|
||||
if ($value) {
|
||||
$this->exception_time = $this->time;
|
||||
$this->exception_end_time = $this->end_time;
|
||||
}
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
if (!trim($this->title)) return;
|
||||
|
||||
$event = Event::findOrFail($this->eventId);
|
||||
$tz = auth()->user()->timezone;
|
||||
|
||||
// 🔥 EXCEPTIONS
|
||||
$exceptions = $event->exceptions ?? [];
|
||||
|
||||
if (is_string($exceptions)) {
|
||||
$exceptions = json_decode($exceptions, true);
|
||||
}
|
||||
|
||||
if (!is_array($exceptions)) {
|
||||
$exceptions = [];
|
||||
}
|
||||
|
||||
$exceptions = collect($exceptions)
|
||||
->filter(fn($ex) =>
|
||||
isset($ex['date']) &&
|
||||
$ex['date'] >= $this->start_date &&
|
||||
$ex['date'] <= $this->end_date
|
||||
)
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
// alte entfernen
|
||||
$exceptions = collect($exceptions)
|
||||
->reject(fn($ex) => $ex['date'] === $this->date)
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
// 🔥 EXCEPTION SETZEN
|
||||
if ($this->use_exception) {
|
||||
|
||||
$exceptions[] = [
|
||||
'date' => $this->date,
|
||||
'start' => $this->exception_time,
|
||||
'end' => $this->exception_end_time,
|
||||
];
|
||||
|
||||
$event->update([
|
||||
'exceptions' => $exceptions
|
||||
]);
|
||||
|
||||
} else {
|
||||
|
||||
// 🔥 GLOBAL UPDATE
|
||||
if ($this->is_all_day) {
|
||||
|
||||
$start = Carbon::createFromFormat('Y-m-d', $this->start_date, 'UTC')->startOfDay();
|
||||
$end = Carbon::createFromFormat('Y-m-d', $this->end_date ?? $this->start_date, 'UTC')->endOfDay();
|
||||
|
||||
} else {
|
||||
|
||||
$start = Carbon::parse(
|
||||
$this->start_date . ' ' . ($this->time ?? '00:00'),
|
||||
$tz
|
||||
)->setTimezone('UTC');
|
||||
|
||||
$end = Carbon::parse(
|
||||
$this->end_date . ' ' . ($this->end_time ?? $this->time ?? '00:00'),
|
||||
$tz
|
||||
)->setTimezone('UTC');
|
||||
}
|
||||
|
||||
$event->update([
|
||||
'title' => $this->title,
|
||||
'color' => $this->color,
|
||||
'starts_at' => $start,
|
||||
'ends_at' => $end,
|
||||
'is_all_day' => $this->is_all_day,
|
||||
'exceptions' => $exceptions,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->dispatch('eventCreated');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.calendar.modals.event-modal');
|
||||
}
|
||||
}
|
||||
|
||||
//class EventModal extends ModalComponent
|
||||
//{
|
||||
// public $eventId = null;
|
||||
//
|
||||
// public $date;
|
||||
//
|
||||
// public $title = '';
|
||||
// public $color = '#6366f1';
|
||||
//
|
||||
// public $time = '12:00';
|
||||
// public $end_time = null;
|
||||
//
|
||||
// public $exception_time = null;
|
||||
// public $exception_end_time = null;
|
||||
//
|
||||
// public $start_date = null;
|
||||
// public $end_date = null;
|
||||
// public $is_all_day = false;
|
||||
//
|
||||
// // 🔥 NEU
|
||||
// public $use_exception = false;
|
||||
//
|
||||
// public function mount($date = null, $eventId = null)
|
||||
// {
|
||||
// $tz = auth()->user()->timezone;
|
||||
//
|
||||
// $this->eventId = $eventId;
|
||||
//
|
||||
// if ($eventId) {
|
||||
//
|
||||
// $event = Event::findOrFail($eventId);
|
||||
//
|
||||
// $this->title = $event->title;
|
||||
// $this->color = $event->color ?? '#6366f1';
|
||||
//
|
||||
// $start = $event->starts_at->timezone($tz);
|
||||
// $end = $event->ends_at?->timezone($tz);
|
||||
//
|
||||
// $this->start_date = $start->format('Y-m-d');
|
||||
// $this->end_date = $end ? $end->format('Y-m-d') : $this->start_date;
|
||||
//
|
||||
// $this->date = $date ?? $this->start_date;
|
||||
//
|
||||
// $displayStart = $start;
|
||||
// $displayEnd = $end;
|
||||
//
|
||||
// $exceptions = $event->exceptions ?? [];
|
||||
//
|
||||
// if (is_string($exceptions)) {
|
||||
// $exceptions = json_decode($exceptions, true);
|
||||
// }
|
||||
//
|
||||
// // 🔥 CHECK: hat dieser Tag Exception?
|
||||
// if (is_array($exceptions)) {
|
||||
// foreach ($exceptions as $ex) {
|
||||
//
|
||||
// if (($ex['date'] ?? null) === $this->date) {
|
||||
//
|
||||
// $this->use_exception = true;
|
||||
//
|
||||
// if (!empty($ex['start'])) {
|
||||
// $displayStart = $start->copy()->setTimeFromTimeString($ex['start']);
|
||||
// }
|
||||
//
|
||||
// if (!empty($ex['end'])) {
|
||||
// $displayEnd = $end
|
||||
// ? $end->copy()->setTimeFromTimeString($ex['end'])
|
||||
// : $start->copy()->setTimeFromTimeString($ex['end']);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// $this->time = $displayStart->format('H:i');
|
||||
// $this->end_time = $displayEnd?->format('H:i');
|
||||
//
|
||||
// $this->is_all_day = $event->is_all_day ?? false;
|
||||
//
|
||||
// if ($this->is_all_day) {
|
||||
// $this->time = null;
|
||||
// $this->end_time = null;
|
||||
// }
|
||||
//
|
||||
// } else {
|
||||
//
|
||||
// $this->date = $date ?? now($tz)->format('Y-m-d');
|
||||
// $this->start_date = $this->date;
|
||||
// $this->end_date = $this->date;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public function save()
|
||||
// {
|
||||
// if (!trim($this->title)) return;
|
||||
//
|
||||
// $event = Event::findOrFail($this->eventId);
|
||||
//
|
||||
// $tz = auth()->user()->timezone;
|
||||
//
|
||||
// /*
|
||||
// |--------------------------------------------------------------------------
|
||||
// | 🔥 EXCEPTIONS laden
|
||||
// |--------------------------------------------------------------------------
|
||||
// */
|
||||
//
|
||||
// $exceptions = $event->exceptions ?? [];
|
||||
//
|
||||
// if (is_string($exceptions)) {
|
||||
// $exceptions = json_decode($exceptions, true);
|
||||
// }
|
||||
//
|
||||
// if (!is_array($exceptions)) {
|
||||
// $exceptions = [];
|
||||
// }
|
||||
//
|
||||
// // 👉 alten Eintrag entfernen
|
||||
// $exceptions = collect($exceptions)
|
||||
// ->reject(fn($ex) => $ex['date'] === $this->date)
|
||||
// ->values()
|
||||
// ->toArray();
|
||||
//
|
||||
// /*
|
||||
// |--------------------------------------------------------------------------
|
||||
// | 🔥 FALL 1: NUR EXCEPTION (kein global update!)
|
||||
// |--------------------------------------------------------------------------
|
||||
// */
|
||||
//
|
||||
// if ($this->use_exception) {
|
||||
//
|
||||
// $exceptions[] = [
|
||||
// 'date' => $this->date,
|
||||
// 'start' => $this->time,
|
||||
// 'end' => $this->end_time,
|
||||
// ];
|
||||
//
|
||||
// $event->update([
|
||||
// 'exceptions' => $exceptions
|
||||
// ]);
|
||||
//
|
||||
// } else {
|
||||
//
|
||||
// /*
|
||||
// |--------------------------------------------------------------------------
|
||||
// | 🔥 FALL 2: GLOBAL UPDATE
|
||||
// |--------------------------------------------------------------------------
|
||||
// */
|
||||
//
|
||||
// if ($this->is_all_day) {
|
||||
//
|
||||
// $start = Carbon::createFromFormat('Y-m-d', $this->start_date, 'UTC')->startOfDay();
|
||||
// $end = Carbon::createFromFormat('Y-m-d', $this->end_date ?? $this->start_date, 'UTC')->endOfDay();
|
||||
//
|
||||
// } else {
|
||||
//
|
||||
// $start = Carbon::parse(
|
||||
// $this->start_date . ' ' . ($this->time ?? '00:00'),
|
||||
// $tz
|
||||
// )->setTimezone('UTC');
|
||||
//
|
||||
// $end = Carbon::parse(
|
||||
// $this->end_date . ' ' . ($this->end_time ?? $this->time ?? '00:00'),
|
||||
// $tz
|
||||
// )->setTimezone('UTC');
|
||||
// }
|
||||
//
|
||||
// $event->update([
|
||||
// 'title' => $this->title,
|
||||
// 'color' => $this->color,
|
||||
// 'starts_at' => $start,
|
||||
// 'ends_at' => $end,
|
||||
// 'is_all_day' => $this->is_all_day,
|
||||
// 'exceptions' => $exceptions,
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// $this->dispatch('eventCreated');
|
||||
// $this->closeModal();
|
||||
// }
|
||||
//
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.calendar.modals.event-modal');
|
||||
// }
|
||||
//}
|
||||
|
||||
//
|
||||
//
|
||||
//namespace App\Livewire\Calendar\Modals;
|
||||
//
|
||||
//use App\Models\Event;
|
||||
//use Carbon\Carbon;
|
||||
//use LivewireUI\Modal\ModalComponent;
|
||||
//
|
||||
//class EventModal extends ModalComponent
|
||||
//{
|
||||
// public $eventId = null;
|
||||
//
|
||||
// public $date;
|
||||
// public $title = '';
|
||||
// public $color = '#6366f1';
|
||||
//
|
||||
// public $time = '12:00';
|
||||
// public $end_time = null;
|
||||
//
|
||||
// public $start_date = null;
|
||||
// public $end_date = null;
|
||||
//
|
||||
// public $is_all_day = false;
|
||||
//
|
||||
// public function mount($date = null, $eventId = null)
|
||||
// {
|
||||
// $tz = auth()->user()->timezone;
|
||||
//
|
||||
// $this->eventId = $eventId;
|
||||
//
|
||||
// if ($eventId) {
|
||||
//
|
||||
// $event = Event::findOrFail($eventId);
|
||||
//
|
||||
// $this->title = $event->title;
|
||||
// $this->color = $event->color ?? '#6366f1';
|
||||
//
|
||||
// $start = $event->starts_at->timezone($tz);
|
||||
// $end = $event->ends_at?->timezone($tz);
|
||||
//
|
||||
// // 👉 GLOBAL (immer korrekt)
|
||||
// $this->start_date = $start->format('Y-m-d');
|
||||
// $this->end_date = $end
|
||||
// ? $end->format('Y-m-d')
|
||||
// : $start->format('Y-m-d');
|
||||
//
|
||||
// // 👉 aktueller Klick-Tag
|
||||
// $this->date = $date ?? $this->start_date;
|
||||
//
|
||||
// $displayStart = $start;
|
||||
// $displayEnd = $end;
|
||||
//
|
||||
// // 🔥 EXCEPTIONS
|
||||
// $exceptions = $event->exceptions;
|
||||
//
|
||||
// if (is_string($exceptions)) {
|
||||
// $exceptions = json_decode($exceptions, true);
|
||||
// }
|
||||
//
|
||||
// if (is_array($exceptions) && $this->date) {
|
||||
// foreach ($exceptions as $ex) {
|
||||
//
|
||||
// if (($ex['date'] ?? null) === $this->date) {
|
||||
//
|
||||
// if (!empty($ex['start'])) {
|
||||
// $displayStart = $start->copy()->setTimeFromTimeString($ex['start']);
|
||||
// }
|
||||
//
|
||||
// if (!empty($ex['end'])) {
|
||||
// $displayEnd = $start->copy()->setTimeFromTimeString($ex['end']);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// $this->time = $displayStart->format('H:i');
|
||||
// $this->end_time = $displayEnd?->format('H:i');
|
||||
//
|
||||
// $this->is_all_day = $event->is_all_day ?? false;
|
||||
//
|
||||
// if ($this->is_all_day) {
|
||||
// $this->time = null;
|
||||
// $this->end_time = null;
|
||||
// }
|
||||
//
|
||||
// } else {
|
||||
//
|
||||
// $this->date = $date ?? now($tz)->format('Y-m-d');
|
||||
// $this->start_date = $this->date;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public function save()
|
||||
// {
|
||||
// if (!trim($this->title)) return;
|
||||
//
|
||||
// $user = auth()->user();
|
||||
// $tz = $user->timezone;
|
||||
//
|
||||
// // 🔥 DEFAULT: end_date setzen wenn leer
|
||||
// if (!$this->end_date) {
|
||||
// $this->end_date = $this->start_date;
|
||||
// }
|
||||
//
|
||||
// if ($this->is_all_day) {
|
||||
//
|
||||
// $start = Carbon::parse($this->start_date)->startOfDay();
|
||||
//
|
||||
// $end = Carbon::parse($this->end_date)->endOfDay();
|
||||
//
|
||||
// } else {
|
||||
//
|
||||
// $start = Carbon::parse(
|
||||
// $this->start_date . ' ' . ($this->time ?? '00:00'),
|
||||
// $tz
|
||||
// )->setTimezone('UTC');
|
||||
//
|
||||
// $end = Carbon::parse(
|
||||
// $this->end_date . ' ' . ($this->end_time ?? $this->time ?? '00:00'),
|
||||
// $tz
|
||||
// )->setTimezone('UTC');
|
||||
// }
|
||||
//
|
||||
// Event::updateOrCreate(
|
||||
// ['id' => $this->eventId],
|
||||
// [
|
||||
// 'user_id' => $user->id,
|
||||
// 'title' => $this->title,
|
||||
// 'starts_at' => $start,
|
||||
// 'ends_at' => $end,
|
||||
// 'is_all_day'=> $this->is_all_day,
|
||||
// 'color' => $this->color,
|
||||
// ]
|
||||
// );
|
||||
//
|
||||
// $this->dispatch('eventCreated');
|
||||
// $this->closeModal();
|
||||
// }
|
||||
//
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.calendar.modals.event-modal');
|
||||
// }
|
||||
//}
|
||||
|
||||
//namespace App\Livewire\Calendar\Modals;
|
||||
//
|
||||
//use App\Models\Event;
|
||||
//use Carbon\Carbon;
|
||||
//use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
//class EventModal extends ModalComponent
|
||||
//{
|
||||
// public $eventId = null;
|
||||
//
|
||||
// public $date;
|
||||
// public $title = '';
|
||||
// public $color = '#6366f1';
|
||||
//
|
||||
// public $time = '12:00';
|
||||
//
|
||||
// public $end_date = null;
|
||||
// public $end_time = null;
|
||||
//
|
||||
// public $is_all_day = false;
|
||||
//
|
||||
// public function mount($date = null, $eventId = null)
|
||||
// {
|
||||
// $tz = auth()->user()->timezone;
|
||||
//
|
||||
// $this->eventId = $eventId;
|
||||
//
|
||||
// if ($eventId) {
|
||||
//
|
||||
// $event = Event::findOrFail($eventId);
|
||||
//
|
||||
// $this->title = $event->title;
|
||||
//
|
||||
// // 👉 Klick-Tag verwenden (WICHTIG)
|
||||
// $this->date = $date
|
||||
// ?? $event->starts_at->timezone($tz)->format('Y-m-d');
|
||||
//
|
||||
// $start = $event->starts_at->timezone($tz);
|
||||
// $end = $event->ends_at?->timezone($tz);
|
||||
//
|
||||
// $displayStart = $start;
|
||||
// $displayEnd = $end;
|
||||
//
|
||||
// // 🔥 EXCEPTIONS
|
||||
// $exceptions = $event->exceptions;
|
||||
//
|
||||
// if (is_string($exceptions)) {
|
||||
// $exceptions = json_decode($exceptions, true);
|
||||
// }
|
||||
//
|
||||
// if (is_array($exceptions) && $this->date) {
|
||||
// foreach ($exceptions as $ex) {
|
||||
//
|
||||
// if (($ex['date'] ?? null) === $this->date) {
|
||||
//
|
||||
// if (!empty($ex['start'])) {
|
||||
// $displayStart = $start->copy()->setTimeFromTimeString($ex['start']);
|
||||
// }
|
||||
//
|
||||
// if (!empty($ex['end'])) {
|
||||
// $displayEnd = $end
|
||||
// ? $end->copy()->setTimeFromTimeString($ex['end'])
|
||||
// : $start->copy()->setTimeFromTimeString($ex['end']);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // 👉 Werte setzen
|
||||
// $this->time = $displayStart->format('H:i');
|
||||
//
|
||||
// if ($displayEnd) {
|
||||
//
|
||||
// $eventEndDate = $displayEnd->format('Y-m-d');
|
||||
//
|
||||
// // 👉 IMMER echtes Enddatum setzen
|
||||
// $this->end_date = $eventEndDate;
|
||||
//
|
||||
// $this->end_time = $displayEnd->format('H:i');
|
||||
// }
|
||||
//
|
||||
// // 🔥 ALL DAY LOGIK
|
||||
// $this->is_all_day = $event->is_all_day ?? false;
|
||||
//
|
||||
// // 👉 wenn Urlaub → keine Zeit anzeigen
|
||||
// if ($this->is_all_day) {
|
||||
// $this->time = null;
|
||||
// $this->end_time = null;
|
||||
// }
|
||||
//
|
||||
// } else {
|
||||
// $this->date = $date ?? now($tz)->format('Y-m-d');
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public function save()
|
||||
// {
|
||||
// if (!trim($this->title)) return;
|
||||
//
|
||||
// $user = auth()->user();
|
||||
// $tz = $user->timezone;
|
||||
//
|
||||
// // 🔥 FALL 1: GANZTÄGIG (Urlaub etc.)
|
||||
// if ($this->is_all_day) {
|
||||
//
|
||||
// $start = Carbon::parse($this->date)->startOfDay();
|
||||
//
|
||||
// $end = $this->end_date
|
||||
// ? Carbon::parse($this->end_date)->endOfDay()
|
||||
// : $start->copy()->endOfDay();
|
||||
//
|
||||
// Event::updateOrCreate(
|
||||
// ['id' => $this->eventId],
|
||||
// [
|
||||
// 'user_id' => $user->id,
|
||||
// 'title' => $this->title,
|
||||
// 'starts_at' => $start,
|
||||
// 'ends_at' => $end,
|
||||
// 'is_all_day' => true,
|
||||
// 'color' => $this->color,
|
||||
// ]
|
||||
// );
|
||||
//
|
||||
// } else {
|
||||
//
|
||||
// // 🔥 NORMAL / SEMINAR
|
||||
// $start = Carbon::parse(
|
||||
// $this->date . ' ' . ($this->time ?? '00:00'),
|
||||
// $tz
|
||||
// )->setTimezone('UTC');
|
||||
//
|
||||
// $end = null;
|
||||
//
|
||||
// if ($this->end_date) {
|
||||
//
|
||||
// if ($this->end_time) {
|
||||
// $end = Carbon::parse(
|
||||
// $this->end_date . ' ' . $this->end_time,
|
||||
// $tz
|
||||
// )->setTimezone('UTC');
|
||||
// } else {
|
||||
// $end = Carbon::parse($this->end_date)->endOfDay();
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Event::updateOrCreate(
|
||||
// ['id' => $this->eventId],
|
||||
// [
|
||||
// 'user_id' => $user->id,
|
||||
// 'title' => $this->title,
|
||||
// 'starts_at' => $start,
|
||||
// 'ends_at' => $end,
|
||||
// 'is_all_day' => false,
|
||||
// ]
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// $this->dispatch('eventCreated');
|
||||
// $this->closeModal();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.calendar.modals.event-modal');
|
||||
// }
|
||||
//}
|
||||
|
||||
//
|
||||
//
|
||||
//namespace App\Livewire\Calendar\Modals;
|
||||
//
|
||||
//use App\Models\Event;
|
||||
//use Carbon\Carbon;
|
||||
//use LivewireUI\Modal\ModalComponent;
|
||||
//
|
||||
//class EventModal extends ModalComponent
|
||||
//{
|
||||
// public $eventId = null;
|
||||
//
|
||||
// public $date;
|
||||
// public $title = '';
|
||||
//
|
||||
// public $time = '12:00';
|
||||
//
|
||||
// public $end_date = null;
|
||||
// public $end_time = null;
|
||||
// public $is_all_day = false;
|
||||
//
|
||||
// public function mount($date = null, $eventId = null)
|
||||
// {
|
||||
// $tz = auth()->user()->timezone;
|
||||
//
|
||||
// $this->eventId = $eventId;
|
||||
//
|
||||
// if ($eventId) {
|
||||
//
|
||||
// $event = Event::findOrFail($eventId);
|
||||
//
|
||||
// $this->title = $event->title;
|
||||
//
|
||||
// // ✅ WICHTIG: geklickten Tag verwenden
|
||||
// $this->date = $date
|
||||
// ?? $event->starts_at->timezone($tz)->format('Y-m-d');
|
||||
//
|
||||
// $this->time = $event->starts_at->timezone($tz)->format('H:i');
|
||||
//
|
||||
// $start = $event->starts_at->timezone($tz);
|
||||
// $end = $event->ends_at?->timezone($tz);
|
||||
//
|
||||
// $displayStart = $start;
|
||||
// $displayEnd = $end;
|
||||
//
|
||||
//// 🔥 EXCEPTIONS berücksichtigen
|
||||
// $exceptions = $event->exceptions;
|
||||
//
|
||||
// if (is_string($exceptions)) {
|
||||
// $exceptions = json_decode($exceptions, true);
|
||||
// }
|
||||
//
|
||||
// if (is_array($exceptions) && $date) {
|
||||
//
|
||||
// foreach ($exceptions as $ex) {
|
||||
//
|
||||
// if (($ex['date'] ?? null) === $date) {
|
||||
//
|
||||
// if (!empty($ex['start'])) {
|
||||
// $displayStart = $start->copy()->setTimeFromTimeString($ex['start']);
|
||||
// }
|
||||
//
|
||||
// if (!empty($ex['end'])) {
|
||||
// $displayEnd = $start->copy()->setTimeFromTimeString($ex['end']);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//// 🔥 Werte ins Modal übernehmen
|
||||
// $this->time = $displayStart->format('H:i');
|
||||
//
|
||||
// if ($displayEnd) {
|
||||
// $this->end_time = $displayEnd->format('H:i');
|
||||
// $this->end_date = $displayEnd->format('Y-m-d');
|
||||
// }
|
||||
//
|
||||
// } else {
|
||||
// $this->date = $date ?? now($tz)->format('Y-m-d');
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public function save()
|
||||
// {
|
||||
// if (!trim($this->title)) return;
|
||||
//
|
||||
// $user = auth()->user();
|
||||
// $tz = $user->timezone;
|
||||
//
|
||||
// $start = Carbon::parse($this->date . ' ' . $this->time, $tz)->setTimezone('UTC');
|
||||
//
|
||||
// $end = null;
|
||||
//
|
||||
// // 🔥 END DATUM gesetzt
|
||||
// if ($this->end_date) {
|
||||
//
|
||||
// // 👉 FALL 1: mit Uhrzeit (Seminar)
|
||||
// if ($this->end_time) {
|
||||
//
|
||||
// $end = Carbon::parse(
|
||||
// $this->end_date . ' ' . $this->end_time,
|
||||
// $tz
|
||||
// )->setTimezone('UTC');
|
||||
//
|
||||
// } // 👉 FALL 2: ohne Uhrzeit (Urlaub)
|
||||
// else {
|
||||
//
|
||||
// $end = Carbon::parse($this->end_date)->endOfDay();
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Event::updateOrCreate(
|
||||
// ['id' => $this->eventId],
|
||||
// [
|
||||
// 'user_id' => $user->id,
|
||||
// 'title' => $this->title,
|
||||
// 'starts_at' => $start,
|
||||
// 'ends_at' => $end,
|
||||
// 'is_all_day' => !$this->end_time && $this->end_date,
|
||||
// ]
|
||||
// );
|
||||
//
|
||||
// $this->dispatch('eventCreated');
|
||||
// $this->closeModal();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.calendar.modals.event-modal');
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Calendar;
|
||||
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class Sidebar extends Component
|
||||
{
|
||||
public $open = false;
|
||||
public $mode = 'create'; // create | edit
|
||||
|
||||
public $eventId = null;
|
||||
public $date = null;
|
||||
public $time = null;
|
||||
|
||||
protected $listeners = [
|
||||
'sidebar:openEvent' => 'openEvent',
|
||||
'sidebar:createEvent' => 'createEvent',
|
||||
'sidebar:close' => 'close',
|
||||
|
||||
'event:save' => 'saveEvent',
|
||||
'event:delete' => 'deleteEvent',
|
||||
];
|
||||
|
||||
public function saveEvent()
|
||||
{
|
||||
$this->dispatch('eventForm:save');
|
||||
}
|
||||
|
||||
public function deleteEvent()
|
||||
{
|
||||
$this->dispatch('eventForm:delete');
|
||||
}
|
||||
|
||||
public function openEvent($eventId, $date = null)
|
||||
{
|
||||
$this->eventId = $eventId;
|
||||
$this->date = $date; // 🔥 WICHTIG
|
||||
$this->mode = 'edit';
|
||||
$this->open = true;
|
||||
}
|
||||
|
||||
public function createEvent($date = null, $time = null)
|
||||
{
|
||||
$this->eventId = null;
|
||||
$this->date = $date;
|
||||
$this->time = $time;
|
||||
$this->mode = 'create';
|
||||
$this->open = true;
|
||||
}
|
||||
|
||||
public function close()
|
||||
{
|
||||
$this->reset(['eventId', 'date', 'time']);
|
||||
$this->open = false;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.calendar.sidebar');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,662 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Calendar;
|
||||
|
||||
use App\Models\Event;
|
||||
use Carbon\Carbon;
|
||||
use Livewire\Attributes\Middleware;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Middleware('user')]
|
||||
class WeekCanvas extends Component
|
||||
{
|
||||
public string $view = 'week';
|
||||
public string $currentDate = '';
|
||||
public int $hourStart = 0;
|
||||
public int $hourEnd = 24;
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
$userId = auth()->id();
|
||||
|
||||
return [
|
||||
'eventCreated' => '$refresh',
|
||||
"echo-private:calendar.{$userId},.calendar.updated" => '$refresh',
|
||||
];
|
||||
}
|
||||
|
||||
protected $queryString = [
|
||||
'view' => ['except' => 'week'], // ?view= nur wenn NICHT week
|
||||
'currentDate' => ['except' => '', 'as' => 'date'], // als ?date=YYYY-MM-DD in der URL
|
||||
];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// Livewire füllt $currentDate aus ?date= bereits vor mount() –
|
||||
// nur setzen wenn noch leer (kein URL-Parameter vorhanden)
|
||||
if (empty($this->currentDate)) {
|
||||
$this->currentDate = now(auth()->user()->timezone)->toDateString();
|
||||
}
|
||||
}
|
||||
|
||||
public function navigate(string $direction): void
|
||||
{
|
||||
$date = Carbon::parse($this->currentDate, auth()->user()->timezone);
|
||||
|
||||
match ($this->view) {
|
||||
'week' => $direction === 'prev' ? $date->subWeek() : $date->addWeek(),
|
||||
'month' => $direction === 'prev' ? $date->subMonth() : $date->addMonth(),
|
||||
'day' => $direction === 'prev' ? $date->subDay() : $date->addDay(),
|
||||
};
|
||||
|
||||
$this->currentDate = $date->toDateString();
|
||||
}
|
||||
|
||||
public function getTitleStringProperty(): string
|
||||
{
|
||||
$userTz = auth()->user()->timezone;
|
||||
$date = Carbon::parse($this->currentDate, $userTz);
|
||||
$months = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||
|
||||
return match ($this->view) {
|
||||
'month' => $months[$date->month - 1] . ' ' . $date->year,
|
||||
'week' => $this->weekTitleString($date, $months),
|
||||
'day' => $date->isoFormat('dddd, D. MMMM YYYY'),
|
||||
};
|
||||
}
|
||||
|
||||
private function weekTitleString(Carbon $date, array $months): string
|
||||
{
|
||||
$start = $date->copy()->startOfWeek(Carbon::MONDAY);
|
||||
$end = $date->copy()->endOfWeek(Carbon::SUNDAY);
|
||||
|
||||
if ($start->month === $end->month) {
|
||||
return $months[$start->month - 1] . ' ' . $start->year;
|
||||
}
|
||||
|
||||
return $months[$start->month - 1] . ' – ' . $months[$end->month - 1] . ' ' . $end->year;
|
||||
}
|
||||
|
||||
public function getWeekDaysProperty(): array
|
||||
{
|
||||
$userTz = auth()->user()->timezone;
|
||||
$start = Carbon::parse($this->currentDate, $userTz)->startOfWeek(Carbon::MONDAY);
|
||||
|
||||
return collect(range(0, 6))
|
||||
->map(fn($i) => $start->copy()->addDays($i))
|
||||
->all();
|
||||
}
|
||||
|
||||
public function rangeStart(): Carbon
|
||||
{
|
||||
$date = Carbon::parse($this->currentDate, auth()->user()->timezone);
|
||||
|
||||
return match ($this->view) {
|
||||
'month' => $date->copy()->startOfMonth()->startOfWeek(Carbon::MONDAY)->startOfDay()->utc(),
|
||||
'week' => $date->copy()->startOfWeek(Carbon::MONDAY)->startOfDay()->utc(),
|
||||
'day' => $date->copy()->startOfDay()->utc(),
|
||||
};
|
||||
}
|
||||
|
||||
public function rangeEnd(): Carbon
|
||||
{
|
||||
$date = Carbon::parse($this->currentDate, auth()->user()->timezone);
|
||||
|
||||
return match ($this->view) {
|
||||
'month' => $date->copy()->endOfMonth()->endOfWeek(Carbon::SUNDAY)->endOfDay()->utc(),
|
||||
'week' => $date->copy()->endOfWeek(Carbon::SUNDAY)->endOfDay()->utc(),
|
||||
'day' => $date->copy()->endOfDay()->utc(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt einen Termin auf ein neues Datum/Uhrzeit.
|
||||
*
|
||||
* @param string|null $grabDate Das Datum der Kopie, die der User gegriffen hat.
|
||||
* Wenn angegeben, wird der gesamte Termin relativ
|
||||
* verschoben (dayDelta = dropDate – grabDate), statt
|
||||
* den Start absolut auf $newDate zu setzen.
|
||||
* Nötig für mehrtägige Events, die auf mehreren Tagen
|
||||
* gleichzeitig angezeigt werden.
|
||||
*/
|
||||
public function moveEvent(string $eventId, string $newDate, int $newHour, int $newMinute = 0, ?string $grabDate = null): void
|
||||
{
|
||||
$event = Event::where('user_id', auth()->id())->findOrFail($eventId);
|
||||
$userTz = auth()->user()->timezone;
|
||||
|
||||
if ($grabDate !== null) {
|
||||
// Relatives Verschieben: Drop-Tag – Grab-Tag = dayDelta
|
||||
$grabDT = Carbon::parse($grabDate, $userTz)->startOfDay();
|
||||
$dropDT = Carbon::parse($newDate, $userTz)->startOfDay();
|
||||
$dayDelta = (int) $grabDT->diffInDays($dropDT, false); // signed
|
||||
|
||||
$eventStartLocal = $event->starts_at->copy()->setTimezone($userTz)->addDays($dayDelta);
|
||||
$newStart = $eventStartLocal->setHour($newHour)->setMinute($newMinute)->setSecond(0)->utc();
|
||||
} else {
|
||||
$newStart = Carbon::parse($newDate, $userTz)
|
||||
->setHour($newHour)->setMinute($newMinute)->setSecond(0)->utc();
|
||||
}
|
||||
|
||||
$duration = $event->ends_at
|
||||
? $event->starts_at->diffInMinutes($event->ends_at)
|
||||
: 60;
|
||||
|
||||
$event->starts_at = $newStart;
|
||||
$event->ends_at = $newStart->copy()->addMinutes($duration);
|
||||
$event->start_date = $newStart->copy()->setTimezone($userTz)->toDateString();
|
||||
$event->end_date = $newStart->copy()->addMinutes($duration)->setTimezone($userTz)->toDateString();
|
||||
$event->save();
|
||||
}
|
||||
|
||||
public function moveAllDayEvent(string $eventId, int $dayDelta): void
|
||||
{
|
||||
if ($dayDelta === 0) return;
|
||||
|
||||
$event = Event::where('user_id', auth()->id())->findOrFail($eventId);
|
||||
$userTz = auth()->user()->timezone;
|
||||
|
||||
// Originale Timestamps in User-TZ (Uhrzeit bleibt erhalten)
|
||||
$startLocal = $event->starts_at->copy()->setTimezone($userTz);
|
||||
$endLocal = $event->ends_at
|
||||
? $event->ends_at->copy()->setTimezone($userTz)
|
||||
: $startLocal->copy();
|
||||
|
||||
// Beide Timestamps um exakt $dayDelta Tage verschieben (Uhrzeit bleibt)
|
||||
$newStartLocal = $startLocal->copy()->addDays($dayDelta);
|
||||
$newEndLocal = $endLocal->copy()->addDays($dayDelta);
|
||||
|
||||
// Datum-Spalten (lesbar) und Timestamp-Spalten (UTC) synchron schreiben
|
||||
$event->start_date = $newStartLocal->toDateString();
|
||||
$event->end_date = $newEndLocal->toDateString();
|
||||
$event->starts_at = $newStartLocal->copy()->utc();
|
||||
$event->ends_at = $newEndLocal->copy()->utc();
|
||||
$event->save();
|
||||
}
|
||||
|
||||
public function openSidebar(string $eventId, string $date): void
|
||||
{
|
||||
$this->dispatch('sidebar:openEvent', eventId: $eventId, date: $date);
|
||||
}
|
||||
|
||||
public function createEventAt(string $date, int $hour = 9, int $minute = 0): void
|
||||
{
|
||||
$time = str_pad($hour, 2, '0', STR_PAD_LEFT) . ':' . str_pad($minute, 2, '0', STR_PAD_LEFT);
|
||||
$this->dispatch('sidebar:createEvent', date: $date, time: $time);
|
||||
}
|
||||
|
||||
public function resizeEvent(string $eventId, float $deltaHours): void
|
||||
{
|
||||
$event = Event::where('user_id', auth()->id())->findOrFail($eventId);
|
||||
$userTz = auth()->user()->timezone;
|
||||
$minEnd = $event->starts_at->copy()->addMinutes(15);
|
||||
|
||||
$newEnd = $event->ends_at
|
||||
? $event->ends_at->copy()->addMinutes((int)round($deltaHours * 60))
|
||||
: $event->starts_at->copy()->addMinutes(60);
|
||||
|
||||
if ($newEnd->lt($minEnd)) $newEnd = $minEnd;
|
||||
|
||||
$event->ends_at = $newEnd->copy()->utc();
|
||||
$event->end_date = $newEnd->copy()->setTimezone($userTz)->toDateString();
|
||||
$event->save();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$userTz = auth()->user()->timezone;
|
||||
|
||||
$events = Event::where('user_id', auth()->id())
|
||||
->where(function ($q) {
|
||||
$q->whereBetween('starts_at', [$this->rangeStart(), $this->rangeEnd()])
|
||||
->orWhereBetween('ends_at', [$this->rangeStart(), $this->rangeEnd()])
|
||||
->orWhere(fn($q2) => $q2
|
||||
->where('starts_at', '<=', $this->rangeStart())
|
||||
->where('ends_at', '>=', $this->rangeEnd())
|
||||
);
|
||||
})
|
||||
->orderBy('starts_at')
|
||||
->get()
|
||||
->map(function ($event) use ($userTz) {
|
||||
$event->starts_local = $event->starts_at->setTimezone($userTz);
|
||||
$event->ends_local = $event->ends_at?->setTimezone($userTz);
|
||||
return $event;
|
||||
});
|
||||
|
||||
// Mehrtägige Timed-Events (Start- und Enddatum unterschiedlich) gehören
|
||||
// zusammen mit ganztägigen Events in den All-Day-Strip oben.
|
||||
$allDayEvents = $events->filter(fn($e) =>
|
||||
$e->is_all_day
|
||||
|| ($e->ends_local && $e->starts_local->toDateString() !== $e->ends_local->toDateString())
|
||||
);
|
||||
$timedEvents = $events->filter(fn($e) =>
|
||||
!$e->is_all_day
|
||||
&& (!$e->ends_local || $e->starts_local->toDateString() === $e->ends_local->toDateString())
|
||||
);
|
||||
|
||||
// Timed Events pro Wochentag – inkl. mehrtägiger Events, die den Tag überspannen
|
||||
$weekDaysList = $this->weekDays;
|
||||
$eventsByDay = collect(range(0, 6))->map(function ($i) use ($timedEvents, $weekDaysList) {
|
||||
$dayDate = $weekDaysList[$i]->toDateString();
|
||||
return $timedEvents
|
||||
->filter(function ($e) use ($dayDate) {
|
||||
$eStart = $e->starts_local->toDateString();
|
||||
$eEnd = $e->ends_local ? $e->ends_local->toDateString() : $eStart;
|
||||
return $eStart <= $dayDate && $eEnd >= $dayDate;
|
||||
})
|
||||
->values();
|
||||
})->all();
|
||||
|
||||
// ── All-Day Events: einzelne Blöcke mit spaltenübergreifenden Positionen ──────
|
||||
$weekStart = $weekDaysList[0]->toDateString();
|
||||
$weekEnd = $weekDaysList[6]->toDateString();
|
||||
|
||||
// Sortiere nach Startdatum → längere Events bekommen zuerst eine Zeile
|
||||
$sortedAllDay = $allDayEvents->sortBy(fn($e) => $e->starts_local->toDateString())->values();
|
||||
|
||||
$rowSlots = []; // $rowSlots[$row][$col] = true → belegt
|
||||
$allDayRows = [];
|
||||
|
||||
foreach ($sortedAllDay as $e) {
|
||||
$eStart = $e->starts_local->toDateString();
|
||||
$eEnd = $e->ends_local ? $e->ends_local->toDateString() : $eStart;
|
||||
|
||||
// Auf sichtbare Woche clippen
|
||||
$visStart = max($eStart, $weekStart);
|
||||
$visEnd = min($eEnd, $weekEnd);
|
||||
|
||||
$startCol = (int) min(6, max(0, $weekDaysList[0]->copy()->diffInDays(Carbon::parse($visStart))));
|
||||
$endCol = (int) min(6, max(0, $weekDaysList[0]->copy()->diffInDays(Carbon::parse($visEnd))));
|
||||
|
||||
// Erste freie Zeile ohne Konflikt suchen
|
||||
$row = 0;
|
||||
while (true) {
|
||||
$conflict = false;
|
||||
for ($c = $startCol; $c <= $endCol; $c++) {
|
||||
if (!empty($rowSlots[$row][$c])) { $conflict = true; break; }
|
||||
}
|
||||
if (!$conflict) break;
|
||||
$row++;
|
||||
}
|
||||
for ($c = $startCol; $c <= $endCol; $c++) {
|
||||
$rowSlots[$row][$c] = true;
|
||||
}
|
||||
|
||||
$allDayRows[] = [
|
||||
'id' => $e->id,
|
||||
'title' => $e->title,
|
||||
'color' => $e->color,
|
||||
'startCol' => $startCol,
|
||||
'endCol' => $endCol,
|
||||
'row' => $row,
|
||||
'startDate' => $eStart,
|
||||
'startsInWeek' => $eStart >= $weekStart,
|
||||
'endsInWeek' => $eEnd <= $weekEnd,
|
||||
'isAllDay' => (bool) $e->is_all_day,
|
||||
'startTime' => $e->is_all_day ? null : $e->starts_local->format('H:i'),
|
||||
'endTime' => $e->is_all_day ? null : $e->ends_local?->format('H:i'),
|
||||
];
|
||||
}
|
||||
|
||||
$maxRow = empty($allDayRows) ? 0 : max(array_column($allDayRows, 'row'));
|
||||
$allDayHeight = ($maxRow + 1) * 26 + 8; // 26 px pro Zeile + 8 px Padding
|
||||
$hasAllDay = !empty($allDayRows);
|
||||
|
||||
// ── Überlappungs-Layout: gestapeltes Stacking nur bei echter Zeitüberschneidung ──
|
||||
// Algorithmus: Greedy-Tiefenzuweisung (Intervall-Graph-Coloring)
|
||||
// depth=0 → volle Breite / kein Offset; depth=N → N×OFFSET_PX links eingerückt
|
||||
$layoutsByDay = [];
|
||||
foreach (range(0, 6) as $dayIndex) {
|
||||
$dayEvs = collect($eventsByDay[$dayIndex] ?? [])
|
||||
->sortBy(fn($e) => $e->starts_local->hour * 60 + $e->starts_local->minute)
|
||||
->values();
|
||||
|
||||
$assigned = []; // ['start', 'end', 'depth'] – bereits verarbeitete Events
|
||||
$layouts = [];
|
||||
|
||||
foreach ($dayEvs as $event) {
|
||||
$startLocal = $event->starts_local;
|
||||
$endLocal = $event->ends_local ?? $startLocal->copy()->addHour();
|
||||
$startDec = $startLocal->hour + $startLocal->minute / 60;
|
||||
$endDec = min($endLocal->hour + $endLocal->minute / 60, 24.0);
|
||||
|
||||
// Tiefen aller zeitlich überlappenden Vorgänger sammeln
|
||||
$usedDepths = [];
|
||||
foreach ($assigned as $a) {
|
||||
if ($startDec < $a['end'] && $endDec > $a['start']) {
|
||||
$usedDepths[] = $a['depth'];
|
||||
}
|
||||
}
|
||||
|
||||
// Kleinste freie Tiefe wählen
|
||||
$depth = 0;
|
||||
while (in_array($depth, $usedDepths)) {
|
||||
$depth++;
|
||||
}
|
||||
|
||||
$assigned[] = ['start' => $startDec, 'end' => $endDec, 'depth' => $depth];
|
||||
$layouts[$event->id] = ['depth' => $depth];
|
||||
}
|
||||
|
||||
$layoutsByDay[$dayIndex] = $layouts;
|
||||
}
|
||||
|
||||
// return match ($this->view) {
|
||||
// 'month' => view('livewire.calendar.month-canvas', [
|
||||
// 'events' => $events,
|
||||
// 'currentDate' => $this->currentDate,
|
||||
// ]),
|
||||
//
|
||||
// 'day' => view('livewire.calendar.day-canvas', [
|
||||
// 'events' => $events,
|
||||
// 'currentDate' => $this->currentDate,
|
||||
// ]),
|
||||
//
|
||||
// default => view('livewire.calendar.week-canvas', [
|
||||
// 'eventsByDay' => $eventsByDay,
|
||||
// 'layoutsByDay' => $layoutsByDay,
|
||||
// 'allDayRows' => $allDayRows,
|
||||
// 'allDayHeight' => $allDayHeight,
|
||||
// 'hasAllDay' => $hasAllDay,
|
||||
// 'weekDays' => $this->weekDays,
|
||||
// 'hourStart' => 0,
|
||||
// 'hourEnd' => 24,
|
||||
// ])
|
||||
// };
|
||||
|
||||
return view('livewire.calendar.week-canvas', [
|
||||
'currentDate' => $this->currentDate,
|
||||
'events' => $events,
|
||||
'eventsByDay' => $eventsByDay,
|
||||
'layoutsByDay' => $layoutsByDay,
|
||||
'allDayRows' => $allDayRows,
|
||||
'allDayHeight' => $allDayHeight,
|
||||
'hasAllDay' => $hasAllDay,
|
||||
'weekDays' => $this->weekDays,
|
||||
'hourStart' => 0,
|
||||
'hourEnd' => 24,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
//namespace App\Livewire\Calendar;
|
||||
//
|
||||
//use Livewire\Component;
|
||||
//use App\Models\Event;
|
||||
//use Carbon\Carbon;
|
||||
//
|
||||
//class WeekCanvas extends Component
|
||||
//{
|
||||
// public string $view = 'week';
|
||||
// public string $currentDate;
|
||||
//
|
||||
// public function mount()
|
||||
// {
|
||||
// $this->currentDate = now()->toDateString();
|
||||
// }
|
||||
//
|
||||
// public function navigate(string $direction)
|
||||
// {
|
||||
// $date = Carbon::parse($this->currentDate);
|
||||
// match ($this->view) {
|
||||
// 'week' => $direction === 'prev' ? $date->subWeek() : $date->addWeek(),
|
||||
// 'month' => $direction === 'prev' ? $date->subMonth() : $date->addMonth(),
|
||||
// 'day' => $direction === 'prev' ? $date->subDay() : $date->addDay(),
|
||||
// };
|
||||
// $this->currentDate = $date->toDateString();
|
||||
// }
|
||||
//
|
||||
// public function moveEvent(string $eventId, string $newDate, int $newHour): void
|
||||
// {
|
||||
// $event = Event::where('user_id', auth()->id())->findOrFail($eventId);
|
||||
// $userTz = auth()->user()->timezone;
|
||||
// $duration = $event->ends_at
|
||||
// ? $event->starts_at->diffInMinutes($event->ends_at)
|
||||
// : 60;
|
||||
//
|
||||
// $newStart = Carbon::parse($newDate, $userTz)
|
||||
// ->setHour($newHour)
|
||||
// ->setMinute(0)
|
||||
// ->setSecond(0)
|
||||
// ->utc();
|
||||
//
|
||||
// $event->starts_at = $newStart;
|
||||
// $event->ends_at = $newStart->copy()->addMinutes($duration);
|
||||
// $event->start_date = $newStart->copy()->setTimezone($userTz)->toDateString();
|
||||
// $event->end_date = $newStart->copy()->addMinutes($duration)->setTimezone($userTz)->toDateString();
|
||||
// $event->save();
|
||||
// }
|
||||
//
|
||||
// public function resizeEvent(string $eventId, float $deltaHours): void
|
||||
// {
|
||||
// $event = Event::where('user_id', auth()->id())->findOrFail($eventId);
|
||||
// $userTz = auth()->user()->timezone;
|
||||
// $minEnd = $event->starts_at->copy()->addMinutes(30);
|
||||
//
|
||||
// $newEnd = $event->ends_at
|
||||
// ? $event->ends_at->copy()->addMinutes((int)($deltaHours * 60))
|
||||
// : $event->starts_at->copy()->addMinutes(60);
|
||||
//
|
||||
// // Minimum 30 Minuten
|
||||
// if ($newEnd->lt($minEnd)) {
|
||||
// $newEnd = $minEnd;
|
||||
// }
|
||||
//
|
||||
// $event->ends_at = $newEnd->utc();
|
||||
// $event->end_date = $newEnd->setTimezone($userTz)->toDateString();
|
||||
// $event->save();
|
||||
// }
|
||||
//
|
||||
// public function rangeStart(): Carbon
|
||||
// {
|
||||
// $date = Carbon::parse($this->currentDate, auth()->user()->timezone);
|
||||
//
|
||||
// return match($this->view) {
|
||||
// 'month' => $date->copy()->startOfMonth()->startOfWeek(Carbon::MONDAY)->startOfDay()->utc(),
|
||||
// 'week' => $date->copy()->startOfWeek(Carbon::MONDAY)->startOfDay()->utc(),
|
||||
// 'day' => $date->copy()->startOfDay()->utc(),
|
||||
// };
|
||||
// }
|
||||
//
|
||||
// public function rangeEnd(): Carbon
|
||||
// {
|
||||
// $date = Carbon::parse($this->currentDate, auth()->user()->timezone);
|
||||
//
|
||||
// return match($this->view) {
|
||||
// 'month' => $date->copy()->endOfMonth()->endOfWeek(Carbon::SUNDAY)->endOfDay()->utc(),
|
||||
// 'week' => $date->copy()->endOfWeek(Carbon::SUNDAY)->endOfDay()->utc(),
|
||||
// 'day' => $date->copy()->endOfDay()->utc(),
|
||||
// };
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// $userTz = auth()->user()->timezone;
|
||||
//
|
||||
// $events = Event::where('user_id', auth()->id())
|
||||
// ->where(function ($q) {
|
||||
// // Events die in den Range fallen — auch mehrtägige
|
||||
// $q->whereBetween('starts_at', [$this->rangeStart(), $this->rangeEnd()])
|
||||
// ->orWhereBetween('ends_at', [$this->rangeStart(), $this->rangeEnd()])
|
||||
// ->orWhere(function ($q2) {
|
||||
// // Events die den ganzen Range überspannen
|
||||
// $q2->where('starts_at', '<=', $this->rangeStart())
|
||||
// ->where('ends_at', '>=', $this->rangeEnd());
|
||||
// });
|
||||
// })
|
||||
// ->orderBy('starts_at')
|
||||
// ->get()
|
||||
// ->map(function ($event) use ($userTz) {
|
||||
// // UTC → User-Timezone für die Anzeige
|
||||
// $event->starts_at_local = $event->starts_at->setTimezone($userTz);
|
||||
// $event->ends_at_local = $event->ends_at?->setTimezone($userTz);
|
||||
// return $event;
|
||||
// });
|
||||
//
|
||||
// return view('livewire.calendar.week-canvas', [
|
||||
// 'events' => $events,
|
||||
// 'userTz' => $userTz,
|
||||
// 'weekDays' => $this->getWeekDays(),
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// private function getWeekDays(): array
|
||||
// {
|
||||
// $userTz = auth()->user()->timezone;
|
||||
// $start = Carbon::parse($this->currentDate, $userTz)->startOfWeek(Carbon::MONDAY);
|
||||
//
|
||||
// return collect(range(0, 6))->map(
|
||||
// fn($i) => $start->copy()->addDays($i)
|
||||
// )->all();
|
||||
// }
|
||||
//
|
||||
//}
|
||||
// public $date;
|
||||
// public $events = [];
|
||||
//
|
||||
// public function mount()
|
||||
// {
|
||||
// $tz = auth()->user()->timezone;
|
||||
//
|
||||
// $this->date = $this->date
|
||||
// ? Carbon::parse($this->date, $tz)
|
||||
// : now($tz);
|
||||
//
|
||||
// $this->loadEvents();
|
||||
// }
|
||||
//
|
||||
// public function loadEvents()
|
||||
// {
|
||||
// $tz = auth()->user()->timezone;
|
||||
// $user = auth()->user();
|
||||
//
|
||||
// $start = $this->date->copy()->startOfWeek();
|
||||
// $end = $this->date->copy()->endOfWeek();
|
||||
//
|
||||
// $this->events = $user->events()
|
||||
// ->where(function ($q) use ($start, $end) {
|
||||
// $q->whereBetween('starts_at', [$start, $end])
|
||||
// ->orWhereBetween('ends_at', [$start, $end])
|
||||
// ->orWhere(function ($q2) use ($start, $end) {
|
||||
// $q2->where('starts_at', '<=', $start)
|
||||
// ->where('ends_at', '>=', $end);
|
||||
// });
|
||||
// })
|
||||
// ->get()
|
||||
// ->map(fn($event) => $this->transformEvent($event, $start, $tz));
|
||||
// }
|
||||
//
|
||||
// public function updateEventTimeAndDate($id, $date, $start)
|
||||
// {
|
||||
// $event = \App\Models\Event::findOrFail($id);
|
||||
//
|
||||
// $tz = auth()->user()->timezone;
|
||||
//
|
||||
// $start = \Carbon\Carbon::parse("$date $start", $tz);
|
||||
//
|
||||
// $duration = $event->ends_at
|
||||
// ? $event->starts_at->diffInSeconds($event->ends_at)
|
||||
// : 3600;
|
||||
//
|
||||
// $event->update([
|
||||
// 'starts_at' => $start->utc(),
|
||||
// 'ends_at' => $start->copy()->addSeconds($duration)->utc(),
|
||||
// ]);
|
||||
//
|
||||
// $this->loadEvents();
|
||||
// }
|
||||
//
|
||||
//
|
||||
// public function updateEventRange($id, $startDate, $endDate, $startTime, $endTime)
|
||||
// {
|
||||
// $event = \App\Models\Event::findOrFail($id);
|
||||
//
|
||||
// $tz = auth()->user()->timezone;
|
||||
//
|
||||
// if (!preg_match('/^\d{2}:\d{2}$/', $startTime)) return;
|
||||
// if (!preg_match('/^\d{2}:\d{2}$/', $endTime)) return;
|
||||
//
|
||||
// $start = \Carbon\Carbon::parse("$startDate $startTime", $tz);
|
||||
// $end = \Carbon\Carbon::parse("$endDate $endTime", $tz);
|
||||
//
|
||||
// // Sicherheit
|
||||
// if ($end->lessThanOrEqualTo($start)) {
|
||||
// $end = $start->copy()->addHour();
|
||||
// }
|
||||
//
|
||||
// $event->update([
|
||||
// 'starts_at' => $start->utc(),
|
||||
// 'ends_at' => $end->utc(),
|
||||
// ]);
|
||||
//
|
||||
// $this->loadEvents();
|
||||
// }
|
||||
//
|
||||
// private function transformEvent($event, $weekStart, $tz)
|
||||
// {
|
||||
// $start = $event->starts_at->copy()->setTimezone($tz);
|
||||
// $end = $event->ends_at?->copy()->setTimezone($tz) ?? $start->copy()->addHour();
|
||||
//
|
||||
// /*
|
||||
// |--------------------------------------------------------------------------
|
||||
// | POSITION
|
||||
// |--------------------------------------------------------------------------
|
||||
// */
|
||||
//
|
||||
// $dayIndex = $weekStart->diffInDays($start->copy()->startOfDay());
|
||||
// $dayIndex = max(0, min(6, $dayIndex));
|
||||
//
|
||||
// $days = $start->copy()->startOfDay()->diffInDays($end->copy()->startOfDay()) + 1;
|
||||
//
|
||||
// /*
|
||||
// |--------------------------------------------------------------------------
|
||||
// | TIME
|
||||
// |--------------------------------------------------------------------------
|
||||
// */
|
||||
//
|
||||
// $hourHeight = 64;
|
||||
//
|
||||
// $gap = 3; // px Abstand
|
||||
//
|
||||
// $top = ($start->hour * $hourHeight)
|
||||
// + (($start->minute / 60) * $hourHeight)
|
||||
// + ($gap / 2);
|
||||
//
|
||||
// $height = max($start->diffInMinutes($end), 30) / 60 * $hourHeight
|
||||
// - $gap;
|
||||
//
|
||||
// $gap = 0.25; // %
|
||||
//
|
||||
// return [
|
||||
// 'id' => $event->id,
|
||||
// 'title' => $event->title,
|
||||
// 'color' => $event->color,
|
||||
// 'left' => ($dayIndex / 7) * 100 + $gap,
|
||||
// 'width' => ($days / 7) * 100 - ($gap * 2),
|
||||
// 'top' => $top,
|
||||
// 'height' => $height,
|
||||
// 'start' => $start,
|
||||
// 'end' => $end,
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
//
|
||||
// public function colorToRgba($color, $opacity = 1): string
|
||||
// {
|
||||
// if (!$color) return 'rgba(99,102,241,0.8)'; // fallback
|
||||
//
|
||||
// $color = ltrim($color, '#');
|
||||
//
|
||||
// $r = hexdec(substr($color, 0, 2));
|
||||
// $g = hexdec(substr($color, 2, 2));
|
||||
// $b = hexdec(substr($color, 4, 2));
|
||||
//
|
||||
// return "rgba($r, $g, $b, $opacity)";
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.calendar.week-canvas');
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Checkout;
|
||||
|
||||
use App\Enums\SubscriptionStatus;
|
||||
use App\Models\Feature;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\StripeService;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
public Plan $plan;
|
||||
public string $billing = 'monthly';
|
||||
|
||||
public function mount(string $planId, string $billing = 'monthly'): void
|
||||
{
|
||||
$this->plan = Plan::public()->where('active', true)->findOrFail($planId);
|
||||
$this->billing = in_array($billing, ['monthly', 'yearly']) ? $billing : 'monthly';
|
||||
}
|
||||
|
||||
/**
|
||||
* 'cancel' → Free Plan gewählt: Stripe-Abo kündigen
|
||||
* 'update' → User hat aktives Stripe-Abo: in-place updaten (keine Weiterleitung zu Stripe)
|
||||
* 'new' → Kein aktives Abo: neuer Stripe Checkout
|
||||
*/
|
||||
#[Computed]
|
||||
public function checkoutType(): string
|
||||
{
|
||||
if ($this->plan->isFree()) {
|
||||
return 'cancel';
|
||||
}
|
||||
|
||||
return $this->activeStripeSub ? 'update' : 'new';
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function activeStripeSub(): ?Subscription
|
||||
{
|
||||
return Subscription::where('user_id', auth()->id())
|
||||
->where('provider', 'stripe')
|
||||
->whereNotNull('provider_subscription_id')
|
||||
->where('status', SubscriptionStatus::Active->value)
|
||||
->latest('created_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function startCheckout(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$stripe = app(StripeService::class);
|
||||
|
||||
try {
|
||||
// ── Free Plan: Stripe-Abo kündigen ────────────────────────────
|
||||
if ($this->plan->isFree()) {
|
||||
$stripe->cancelUserSubscription($user);
|
||||
$this->redirect(route('subscription.index'), navigate: false);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Upgrade / Downgrade: bestehendes Abo in-place updaten ─────
|
||||
$existingSub = $this->activeStripeSub;
|
||||
if ($existingSub) {
|
||||
$stripe->updateStripeSubscription(
|
||||
$existingSub->provider_subscription_id,
|
||||
$this->plan,
|
||||
$this->billing,
|
||||
$user->id
|
||||
);
|
||||
$this->redirect(route('subscription.index'), navigate: false);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Erstes Abo: neuer Stripe Checkout ─────────────────────────
|
||||
$session = $stripe->createCheckoutSession($user, $this->plan, $this->billing);
|
||||
$this->redirect($session->url, navigate: false);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('notify', ['type' => 'error', 'message' => 'Fehler: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function openBillingPortal(): void
|
||||
{
|
||||
$customerId = \App\Models\Subscription::where('user_id', auth()->id())
|
||||
->where('provider', 'stripe')
|
||||
->whereNotNull('provider_customer_id')
|
||||
->latest('created_at')
|
||||
->value('provider_customer_id');
|
||||
|
||||
if (!$customerId) {
|
||||
$this->dispatch('notify', ['type' => 'error', 'message' => 'Kein Stripe-Konto verknüpft.']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$url = app(StripeService::class)->createBillingPortalSession($customerId);
|
||||
$this->redirect($url, navigate: false);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('notify', ['type' => 'error', 'message' => 'Fehler beim Öffnen des Portals.']);
|
||||
}
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function monthlyPrice(): int
|
||||
{
|
||||
return $this->plan->getMonthlyPrice();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function yearlyPrice(): int
|
||||
{
|
||||
return $this->plan->getYearlyPrice();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function activePrice(): int
|
||||
{
|
||||
return $this->billing === 'yearly' ? $this->yearlyPrice : $this->monthlyPrice;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function savings(): int
|
||||
{
|
||||
return ($this->monthlyPrice * 12) - $this->yearlyPrice;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.checkout.index', [
|
||||
'features' => $this->plan->features()->with('group')->orderBy('sort')->get(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Checkout;
|
||||
|
||||
use App\Enums\PaymentStatus;
|
||||
use App\Enums\SubscriptionStatus;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Subscription;
|
||||
use Livewire\Component;
|
||||
|
||||
class Success extends Component
|
||||
{
|
||||
public bool $subscriptionReady = false;
|
||||
public bool $paymentReady = false;
|
||||
public bool $done = false;
|
||||
public string $since = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// 60 Sekunden Fenster – Webhook braucht manchmal ein paar Sekunden
|
||||
$this->since = now()->subMinutes(1)->toDateTimeString();
|
||||
$this->checkStatus();
|
||||
}
|
||||
|
||||
public function checkStatus(): void
|
||||
{
|
||||
if ($this->done) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = auth()->id();
|
||||
|
||||
$sub = Subscription::where('user_id', $userId)
|
||||
->where('provider', 'stripe')
|
||||
->where('status', SubscriptionStatus::Active->value)
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
$this->subscriptionReady = $sub !== null;
|
||||
|
||||
if ($sub) {
|
||||
// Primär: Payment direkt an dieser Subscription
|
||||
$this->paymentReady = Payment::where('subscription_id', $sub->id)
|
||||
->where('status', PaymentStatus::Paid->value)
|
||||
->exists();
|
||||
|
||||
// Fallback: irgendein paid-Payment des Users seit Seitenaufruf
|
||||
// (fängt Race-Conditions ab wo Payment an anderer Sub-ID hängt)
|
||||
if (!$this->paymentReady) {
|
||||
$this->paymentReady = Payment::where('user_id', $userId)
|
||||
->where('status', PaymentStatus::Paid->value)
|
||||
->where('created_at', '>=', $this->since)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
$this->done = $this->subscriptionReady && $this->paymentReady;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.checkout.success')
|
||||
->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Contacts;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\Contact;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
public string $filterType = '';
|
||||
|
||||
// Slide-in Panel
|
||||
public bool $panelOpen = false;
|
||||
public ?string $editId = null;
|
||||
|
||||
// Formularfelder
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
public string $phone = '';
|
||||
public string $type = '';
|
||||
public string $notes = '';
|
||||
public string $birthday = '';
|
||||
|
||||
public function updatedSearch(): void { $this->resetPage(); }
|
||||
public function updatedFilterType(): void { $this->resetPage(); }
|
||||
|
||||
// ── Panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function openCreate(): void
|
||||
{
|
||||
$this->reset(['editId', 'name', 'email', 'phone', 'notes', 'birthday']);
|
||||
$this->type = '';
|
||||
$this->panelOpen = true;
|
||||
}
|
||||
|
||||
public function openEdit(string $id): void
|
||||
{
|
||||
$contact = Contact::forUser(auth()->id())->findOrFail($id);
|
||||
|
||||
$this->editId = $id;
|
||||
$this->name = $contact->name;
|
||||
$this->email = $contact->email ?? '';
|
||||
$this->phone = $contact->phone ?? '';
|
||||
$this->type = $contact->type ?? '';
|
||||
$this->notes = $contact->notes ?? '';
|
||||
$this->birthday = $contact->birthday?->format('Y-m-d') ?? '';
|
||||
|
||||
$this->panelOpen = true;
|
||||
}
|
||||
|
||||
public function closePanel(): void
|
||||
{
|
||||
$this->reset(['panelOpen', 'editId', 'name', 'email', 'phone', 'type', 'notes', 'birthday']);
|
||||
}
|
||||
|
||||
// ── Speichern ─────────────────────────────────────────────────────────
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'type' => ['nullable', 'in:' . implode(',', Contact::TYPES)],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
'birthday' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'name' => $this->name,
|
||||
'email' => $this->email ?: null,
|
||||
'phone' => $this->phone ?: null,
|
||||
'type' => $this->type ?: null,
|
||||
'notes' => $this->notes ?: null,
|
||||
'birthday' => $this->birthday ?: null,
|
||||
];
|
||||
|
||||
if ($this->editId) {
|
||||
Contact::forUser(auth()->id())->where('id', $this->editId)->update($data);
|
||||
|
||||
Activity::log(
|
||||
auth()->id(),
|
||||
Activity::TYPE_CONTACT_UPDATED,
|
||||
Activity::localizedTitle('contact_updated', auth()->user()->locale ?? 'de'),
|
||||
$this->name,
|
||||
);
|
||||
|
||||
$this->dispatch('notify', ['type' => 'success', 'message' => 'Kontakt gespeichert']);
|
||||
} else {
|
||||
Contact::create(array_merge($data, ['user_id' => auth()->id()]));
|
||||
|
||||
Activity::log(
|
||||
auth()->id(),
|
||||
Activity::TYPE_CONTACT_CREATED,
|
||||
Activity::localizedTitle('contact_created', auth()->user()->locale ?? 'de'),
|
||||
$this->name,
|
||||
);
|
||||
|
||||
$this->dispatch('notify', ['type' => 'success', 'message' => 'Kontakt erstellt']);
|
||||
}
|
||||
|
||||
$this->closePanel();
|
||||
}
|
||||
|
||||
// ── Löschen ───────────────────────────────────────────────────────────
|
||||
|
||||
public function delete(string $id): void
|
||||
{
|
||||
$contact = Contact::forUser(auth()->id())->findOrFail($id);
|
||||
$name = $contact->name;
|
||||
$contact->delete();
|
||||
|
||||
Activity::log(auth()->id(), Activity::TYPE_CONTACT_UPDATED, Activity::localizedTitle('contact_deleted', auth()->user()->locale ?? 'de'), $name);
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
|
||||
public function render()
|
||||
{
|
||||
$userId = auth()->id();
|
||||
|
||||
$query = Contact::forUser($userId)->orderBy('name');
|
||||
|
||||
if ($this->search !== '') {
|
||||
$query->search($this->search);
|
||||
}
|
||||
|
||||
if ($this->filterType !== '') {
|
||||
$query->where('type', $this->filterType);
|
||||
}
|
||||
|
||||
$contacts = $query->paginate(20);
|
||||
|
||||
$stats = Contact::forUser($userId)
|
||||
->selectRaw("
|
||||
count(*) as total,
|
||||
sum(case when type = 'privat' then 1 else 0 end) as privat,
|
||||
sum(case when type = 'arbeit' then 1 else 0 end) as arbeit,
|
||||
sum(case when type = 'kunde' then 1 else 0 end) as kunde,
|
||||
sum(case when type = 'sonstiges' then 1 else 0 end) as sonstiges
|
||||
")
|
||||
->first();
|
||||
|
||||
return view('livewire.contacts.index', [
|
||||
'contacts' => $contacts,
|
||||
'stats' => $stats,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\AgentLog;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Event;
|
||||
use App\Models\Note;
|
||||
use App\Models\Task;
|
||||
use Carbon\Carbon;
|
||||
use Livewire\Component;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
public $birthdaysToday = [];
|
||||
public $birthdaysSoon = [];
|
||||
public string $locale = 'de';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->locale = auth()->user()->locale ?? 'de';
|
||||
$tz = auth()->user()->timezone;
|
||||
$today = now($tz)->format('m-d');
|
||||
|
||||
$this->birthdaysToday = Contact::where('user_id', auth()->id())
|
||||
->whereNotNull('birthday')
|
||||
->get()
|
||||
->filter(fn ($c) => Carbon::parse($c->birthday)->format('m-d') === $today)
|
||||
->values();
|
||||
|
||||
$this->birthdaysSoon = Contact::where('user_id', auth()->id())
|
||||
->whereNotNull('birthday')
|
||||
->get()
|
||||
->filter(function ($c) use ($tz) {
|
||||
$days = now($tz)->diffInDays(
|
||||
Carbon::parse($c->birthday)->setYear(now($tz)->year),
|
||||
false
|
||||
);
|
||||
return $days > 0 && $days <= 7;
|
||||
})
|
||||
->sortBy(fn ($c) => Carbon::parse($c->birthday)->format('m-d'))
|
||||
->values();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$user = auth()->user();
|
||||
$userId = $user->id;
|
||||
$tz = $user->timezone ?? 'UTC';
|
||||
|
||||
$today = now($tz)->toDateString();
|
||||
$todayDate = now($tz)->startOfDay()->utc();
|
||||
$todayEnd = now($tz)->endOfDay()->utc();
|
||||
|
||||
// ── Termine heute (inkl. mehrtägige und ganztägige Überschneidungen) ─
|
||||
$todayEvents = Event::where('user_id', $userId)
|
||||
->where('starts_at', '<=', $todayEnd) // startet vor/während heute
|
||||
->where('ends_at', '>=', $todayDate) // endet nach/während heute
|
||||
->orderByDesc('is_all_day') // Ganztag-Termine zuerst
|
||||
->orderBy('starts_at')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
// ── Nächste Termine (ab jetzt) ────────────────────────────────────
|
||||
$upcomingEvents = Event::where('user_id', $userId)
|
||||
->where('starts_at', '>', now()->utc())
|
||||
->orderBy('starts_at')
|
||||
->limit(3)
|
||||
->get();
|
||||
|
||||
// ── Offene Aufgaben (fällig heute oder überfällig + ohne Datum) ───
|
||||
$openTasks = Task::forUser($userId)
|
||||
->where('status', '!=', Task::STATUS_DONE)
|
||||
->orderByRaw("FIELD(priority, 'high', 'medium', 'low')")
|
||||
->orderBy('due_at')
|
||||
->limit(6)
|
||||
->get();
|
||||
|
||||
// ── Letzte Notizen ────────────────────────────────────────────────
|
||||
$recentNotes = Note::forUser($userId)
|
||||
->orderByDesc('pinned')
|
||||
->orderByDesc('created_at')
|
||||
->limit(3)
|
||||
->get();
|
||||
|
||||
// ── Letzte Aktivitäten ────────────────────────────────────────────
|
||||
$recentActivities = Activity::forUser($userId)
|
||||
->latest()
|
||||
->limit(7)
|
||||
->get();
|
||||
|
||||
// ── Statistiken ───────────────────────────────────────────────────
|
||||
$stats = [
|
||||
'events_today' => $todayEvents->count(),
|
||||
'open_tasks' => Task::forUser($userId)->where('status', '!=', Task::STATUS_DONE)->count(),
|
||||
'notes_total' => Note::forUser($userId)->count(),
|
||||
'credits_month' => AgentLog::where('user_id', $userId)
|
||||
->where('created_at', '>=', now()->startOfMonth())
|
||||
->sum('credits'),
|
||||
'overdue_tasks' => Task::forUser($userId)
|
||||
->where('status', '!=', Task::STATUS_DONE)
|
||||
->whereNotNull('due_at')
|
||||
->whereDate('due_at', '<', $today)
|
||||
->count(),
|
||||
];
|
||||
|
||||
// ── Plan & Credits ────────────────────────────────────────────────
|
||||
$subscription = $user->subscription()->with('plan')->first()
|
||||
?? $user->latestSubscription()->with('plan')->first();
|
||||
$plan = $subscription?->plan;
|
||||
$creditLimit = $user->effective_limit;
|
||||
$usagePercent = $user->usage_percent;
|
||||
|
||||
// ── Begrüßung ─────────────────────────────────────────────────────
|
||||
$hour = now($tz)->hour;
|
||||
$greetingKey = match (true) {
|
||||
$hour >= 5 && $hour < 12 => 'dashboard.greeting_morning',
|
||||
$hour >= 12 && $hour < 18 => 'dashboard.greeting_afternoon',
|
||||
default => 'dashboard.greeting_evening',
|
||||
};
|
||||
$greeting = t($greetingKey, [], $this->locale);
|
||||
|
||||
return view('livewire.dashboard.index', [
|
||||
'user' => $user,
|
||||
'firstName' => explode(' ', $user->name)[0],
|
||||
'greeting' => $greeting,
|
||||
'tz' => $tz,
|
||||
'todayEvents' => $todayEvents,
|
||||
'upcomingEvents' => $upcomingEvents,
|
||||
'openTasks' => $openTasks,
|
||||
'recentNotes' => $recentNotes,
|
||||
'recentActivities' => $recentActivities,
|
||||
'stats' => $stats,
|
||||
'plan' => $plan,
|
||||
'subscription' => $subscription,
|
||||
'creditLimit' => $creditLimit,
|
||||
'usagePercent' => $usagePercent,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Homepage;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Agb extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.homepage.agb')
|
||||
->layout('layouts.blank');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Homepage;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Datenschutz extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.homepage.datenschutz')
|
||||
->layout('layouts.blank');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Homepage;
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Component;
|
||||
|
||||
class Example extends Component
|
||||
{
|
||||
public string $notifyEmail = '';
|
||||
public bool $notifySubmitted = false;
|
||||
|
||||
public function submitNotify(): void
|
||||
{
|
||||
$this->validate(['notifyEmail' => 'required|email']);
|
||||
$this->notifySubmitted = true;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.homepage.example', [
|
||||
'userCount' => User::count(),
|
||||
])->layout('layouts.blank');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue