Initial commit — Aziros v1.0.0

develop
boban 2026-04-18 20:53:15 +02:00
commit db8a012f73
400 changed files with 58280 additions and 0 deletions

6
.env Normal file
View File

@ -0,0 +1,6 @@
DB_CONNECTION=mysql
DB_HOST=db
DB_DATABASE=nexxo
DB_USERNAME=nexxo
DB_PASSWORD=5d79bcb6f4ce1955ef835af6
DB_ROOT_PASSWORD=49f12736549babe3a2638078b95b0407

15
.gitignore vendored Normal file
View File

@ -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

22
deploy.sh Executable file
View File

@ -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!"

169
docker-compose.local.yml Normal file
View File

@ -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

166
docker-compose.yml Normal file
View File

@ -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

54
docker/nginx/default.conf Normal file
View File

@ -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;
}

58
docker/nginx/local.conf Normal file
View File

@ -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;
}

17
docker/ntfy/cache/server.yml vendored Normal file
View File

@ -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

60
docker/php/Dockerfile Normal file
View File

@ -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

18
src/.editorconfig Normal file
View File

@ -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

130
src/.env.example Normal file
View File

@ -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=

11
src/.gitattributes vendored Normal file
View File

@ -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

25
src/.gitignore vendored Normal file
View File

@ -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

84
src/CLAUDE.md Normal file
View File

@ -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.

58
src/README.md Normal file
View File

@ -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).

View File

@ -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.");
}
}

View File

@ -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");
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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.");
}
}

View File

@ -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.");
}
}

View File

@ -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
// }
// }
// }
//}

View File

@ -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.");
}
}

View File

@ -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(),
]);
}
}
}
}
}

View File

@ -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;
}
}

View File

@ -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',
};
}
}

View File

@ -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',
};
}
}

View File

@ -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',
};
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum UserStatus: string
{
case Active = 'active';
case Blocked = 'blocked';
case Suspended = 'suspended';
}

View File

@ -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';
}
}

View File

@ -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,
];
}
}

View File

@ -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;
// }
//}

View File

@ -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,
]);
}
}

View File

@ -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,
],
],
]);
}
}

View File

@ -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]);
}
}

View File

@ -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.',
]);
}
}

View File

@ -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,
],
]);
}
}

View File

@ -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.',
]);
}
}

View File

@ -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.',
]);
}
}

View File

@ -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.',
]);
}
}

View File

@ -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.',
]);
}
}

View File

@ -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.',
]);
}
}

View File

@ -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,
]);
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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);
}
}

View File

@ -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'));
// }
//}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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');
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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,
};
}
}

View File

@ -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);
}
}

View File

@ -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');
}
}
}
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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));
}
}

View File

@ -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');
}
}

View File

@ -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');
// }
//}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
// }
//}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
// }
//}

View File

@ -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');
}
}

View File

@ -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');
// }
//}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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