Compare commits
64 Commits
c4d2f6e473
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ffc60aba5 | |||
| 7ab890005b | |||
| b6405764a9 | |||
| 256b311c43 | |||
| 32fe2fbc9b | |||
| e3bc5da7e3 | |||
| b6bfa17980 | |||
| fd81e8ce2d | |||
| 054202dc32 | |||
| 92f54f7103 | |||
| 8f8c5c5a12 | |||
| 187cb4f127 | |||
| 7881508a7b | |||
| 821985469e | |||
| a5257df2b7 | |||
| 342d9d0700 | |||
| d54fc9914d | |||
| f8d1579cb2 | |||
| d80c99c6c0 | |||
| 9c773be3ec | |||
| 9c6878d1bd | |||
| 5f9d00b575 | |||
| 2cc765912e | |||
| b6e66f0e64 | |||
| 0aa95fba47 | |||
| 0b082549b9 | |||
| b0d2aa93ab | |||
| c16dd51199 | |||
| 245caea4dc | |||
| dda118a005 | |||
| 8147fedd04 | |||
| b1c531bb70 | |||
| 9cc1b7072c | |||
| 2968bcf3f8 | |||
| ad0f7a7a01 | |||
| 368b0a7cf7 | |||
| aa375ce0da | |||
| 340e16c610 | |||
| 33b236d881 | |||
| fb7704027b | |||
| e5902706f1 | |||
| 229c100cc4 | |||
| 9a4897bf0c | |||
| d779e4d7a1 | |||
| b2a9350d0f | |||
| d64a67cf76 | |||
| 068bbdf583 | |||
| cc4c07717e | |||
| 28f28be1b8 | |||
| 27bdb942ab | |||
| ebf9f29200 | |||
| 7eaab16e30 | |||
| 6a2dd860fa | |||
| 091fb07646 | |||
| 357a254e82 | |||
| aa93c96d31 | |||
| ca8754cd94 | |||
| 8fdc0d6359 | |||
| df6c3133ec | |||
| f646b6530a | |||
| 7fc4520dbf | |||
| f66bbbf842 | |||
| 4f605451e1 | |||
| dc41862afc |
@@ -0,0 +1,29 @@
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.gitattributes
|
||||
.env
|
||||
.env.*
|
||||
!.env.production.example
|
||||
node_modules
|
||||
npm-debug.log
|
||||
vendor
|
||||
storage/app/*
|
||||
storage/framework/cache/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
bootstrap/cache/*
|
||||
public/storage
|
||||
public/hot
|
||||
*.md
|
||||
!README.md
|
||||
tests
|
||||
.phpunit.result.cache
|
||||
phpunit.xml
|
||||
docker-compose*.yml
|
||||
.editorconfig
|
||||
.styleci.yml
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,82 @@
|
||||
APP_NAME="Teren App"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://localhost:8080
|
||||
|
||||
APP_LOCALE=sl
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=sl_SI
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
APP_MAINTENANCE_STORE=database
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Database
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=teren_app
|
||||
DB_USERNAME=teren_user
|
||||
DB_PASSWORD=local_password
|
||||
|
||||
# Redis
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=
|
||||
SESSION_SECURE_COOKIE=false
|
||||
SESSION_SAME_SITE=lax
|
||||
|
||||
# Cache
|
||||
CACHE_STORE=redis
|
||||
|
||||
# Mail (Mailpit for local testing)
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=mailpit
|
||||
MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
SCOUT_PREFIX=
|
||||
SCOUT_QUEUE=true
|
||||
|
||||
# Sanctum
|
||||
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,localhost:8080,127.0.0.1:8080
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Vite
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_DEV_SERVER_KEY=
|
||||
VITE_DEV_SERVER_CERT=
|
||||
|
||||
# LibreOffice for document previews (Docker container path)
|
||||
LIBREOFFICE_BIN=/usr/bin/soffice
|
||||
|
||||
# Storage configuration for generated previews
|
||||
FILES_PREVIEW_DISK=public
|
||||
FILES_PREVIEW_BASE=previews/casesNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Vite
|
||||
VITE_DEV_SERVER_KEY=
|
||||
VITE_DEV_SERVER_CERT=
|
||||
@@ -0,0 +1,88 @@
|
||||
APP_NAME="Teren App"
|
||||
APP_ENV=production
|
||||
APP_KEY= # Generate with: php artisan key:generate
|
||||
APP_DEBUG=false
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=https://example.com # Your domain
|
||||
|
||||
APP_LOCALE=sl
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=sl_SI
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
APP_MAINTENANCE_STORE=database
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Database
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=teren_app
|
||||
DB_USERNAME=teren_user
|
||||
DB_PASSWORD= # Generate a strong password
|
||||
|
||||
# Redis
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=
|
||||
SESSION_SECURE_COOKIE=true
|
||||
SESSION_SAME_SITE=lax
|
||||
|
||||
# Cache
|
||||
CACHE_STORE=redis
|
||||
|
||||
# pgAdmin
|
||||
PGADMIN_EMAIL=admin@example.com
|
||||
PGADMIN_PASSWORD= # Generate a strong password
|
||||
|
||||
# WireGuard VPN (REQUIRED - app is VPN-only)
|
||||
WG_SERVERURL=vpn.example.com # Your VPS public IP or domain
|
||||
WG_UI_PASSWORD= # Generate a strong password for WireGuard dashboard
|
||||
|
||||
# Mail (configure as needed)
|
||||
MAIL_MAILER=log
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PA
|
||||
SCOUT_DRIVER=database
|
||||
SCOUT_PREFIX=
|
||||
SCOUT_QUEUE=true
|
||||
|
||||
# Sanctum
|
||||
SANCTUM_STATEFUL_DOMAINS=example.com,www.example.com,10.13.13.1
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=error
|
||||
|
||||
# Vite
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# LibreOffice for document previews (Docker container path)
|
||||
LIBREOFFICE_BIN=/usr/bin/soffice
|
||||
|
||||
# Storage configuration for generated previews
|
||||
FILES_PREVIEW_DISK=public
|
||||
FILES_PREVIEW_BASE=previews/cases
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=error
|
||||
+11
-1
@@ -25,7 +25,17 @@ yarn-error.log
|
||||
check-*.php
|
||||
test-*.php
|
||||
fix-*.php
|
||||
clean-*.php
|
||||
mark-*.php
|
||||
|
||||
# Development Documentation
|
||||
IMPORT_*.md
|
||||
V2_*.md
|
||||
V2_*.md
|
||||
REPORTS_*.md
|
||||
DEDUPLICATION_*.md
|
||||
|
||||
# Docker Local Testing
|
||||
docker-compose.local.yaml
|
||||
docker-compose.override.yaml
|
||||
.env.local
|
||||
.env.docker
|
||||
+1045
File diff suppressed because it is too large
Load Diff
+83
@@ -0,0 +1,83 @@
|
||||
ARG PHP_VERSION=8.4
|
||||
FROM php:${PHP_VERSION}-fpm-alpine
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
curl \
|
||||
zip \
|
||||
unzip \
|
||||
supervisor \
|
||||
nginx \
|
||||
postgresql-dev \
|
||||
libpng-dev \
|
||||
libjpeg-turbo-dev \
|
||||
freetype-dev \
|
||||
libwebp-dev \
|
||||
oniguruma-dev \
|
||||
libxml2-dev \
|
||||
linux-headers \
|
||||
${PHPIZE_DEPS}
|
||||
|
||||
# Configure and install PHP extensions
|
||||
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
|
||||
&& docker-php-ext-install -j$(nproc) \
|
||||
pdo_pgsql \
|
||||
pgsql \
|
||||
mbstring \
|
||||
exif \
|
||||
pcntl \
|
||||
bcmath \
|
||||
gd \
|
||||
opcache
|
||||
|
||||
# Install Redis extension via PECL
|
||||
RUN pecl install redis \
|
||||
&& docker-php-ext-enable redis
|
||||
|
||||
# Install LibreOffice from community repository
|
||||
RUN apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community \
|
||||
libreoffice-common \
|
||||
libreoffice-writer \
|
||||
libreoffice-calc
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Create system user to run Composer and Artisan Commands
|
||||
RUN addgroup -g 1000 -S www && \
|
||||
adduser -u 1000 -S www -G www
|
||||
|
||||
# Copy application files (will be overridden by volume mount in local development)
|
||||
COPY --chown=www:www . /var/www
|
||||
|
||||
# Copy supervisor configuration
|
||||
COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
COPY docker/supervisor/conf.d /etc/supervisor/conf.d
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R www:www /var/www \
|
||||
&& chmod -R 755 /var/www/storage \
|
||||
&& chmod -R 755 /var/www/bootstrap/cache
|
||||
|
||||
# PHP Configuration for production
|
||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||
|
||||
# Copy PHP custom configuration
|
||||
COPY docker/php/custom.ini $PHP_INI_DIR/conf.d/custom.ini
|
||||
|
||||
# Configure PHP-FPM to listen on all interfaces (0.0.0.0) instead of just localhost
|
||||
# This is needed for nginx running in a separate container to reach PHP-FPM
|
||||
RUN sed -i 's/listen = 127.0.0.1:9000/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf
|
||||
|
||||
# Expose port 9000 for PHP-FPM
|
||||
EXPOSE 9000
|
||||
|
||||
# Create directories for supervisor logs
|
||||
RUN mkdir -p /var/log/supervisor
|
||||
|
||||
# Start supervisor (which will manage both PHP-FPM and Laravel queue workers)
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
@@ -0,0 +1,343 @@
|
||||
# Local Testing Guide - Windows/Mac/Linux
|
||||
|
||||
This guide helps you test the Teren App Docker setup on your local machine without WireGuard VPN.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Prerequisites
|
||||
|
||||
- Docker Desktop installed and running
|
||||
- Git
|
||||
- 8GB RAM recommended
|
||||
- Ports available: 8080, 5433 (PostgreSQL), 5050, 6379, 9000, 8025, 1025
|
||||
- **Note:** If you have local PostgreSQL on port 5432, the Docker container uses 5433 instead
|
||||
|
||||
### 2. Setup
|
||||
|
||||
```bash
|
||||
# Clone repository (if not already)
|
||||
git clone YOUR_GITEA_URL
|
||||
cd Teren-app
|
||||
|
||||
# Copy local environment file
|
||||
cp .env.local.example .env
|
||||
|
||||
# Start all services
|
||||
docker compose -f docker-compose.local.yaml up -d
|
||||
|
||||
# Wait for services to start (30 seconds)
|
||||
timeout 30
|
||||
|
||||
# Generate application key
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan key:generate
|
||||
|
||||
# Run migrations
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan migrate
|
||||
|
||||
# Seed database (optional)
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan db:seed
|
||||
|
||||
# Install frontend dependencies (if needed)
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. Access Services
|
||||
|
||||
| Service | URL | Credentials |
|
||||
|---------|-----|-------------|
|
||||
| **Laravel App** | http://localhost:8080 | - |
|
||||
| **Portainer** | http://localhost:9000 | Set on first visit |
|
||||
| **pgAdmin** | http://localhost:5050 | admin@local.dev / admin |
|
||||
| **Mailpit** | http://localhost:8025 | - |
|
||||
| **PostgreSQL** | localhost:5433 | teren_user / local_password |
|
||||
| **Redis** | localhost:6379 | - |
|
||||
|
||||
**Note:** PostgreSQL uses port 5433 to avoid conflicts with any local PostgreSQL installation.
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Docker Compose Commands
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker compose -f docker-compose.local.yaml up -d
|
||||
|
||||
# Stop all services
|
||||
docker compose -f docker-compose.local.yaml down
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.local.yaml logs -f
|
||||
|
||||
# View specific service logs
|
||||
docker compose -f docker-compose.local.yaml logs -f app
|
||||
|
||||
# Restart a service
|
||||
docker compose -f docker-compose.local.yaml restart app
|
||||
|
||||
# Rebuild containers
|
||||
docker compose -f docker-compose.local.yaml up -d --build
|
||||
|
||||
# Stop and remove everything (including volumes)
|
||||
docker compose -f docker-compose.local.yaml down -v
|
||||
```
|
||||
|
||||
### Laravel Commands
|
||||
|
||||
```bash
|
||||
# Run artisan commands
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan [command]
|
||||
|
||||
# Examples:
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan migrate
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan db:seed
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan cache:clear
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan config:clear
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan queue:work
|
||||
|
||||
# Run tests
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan test
|
||||
|
||||
# Access container shell
|
||||
docker compose -f docker-compose.local.yaml exec app sh
|
||||
|
||||
# Run Composer commands
|
||||
docker compose -f docker-compose.local.yaml exec app composer install
|
||||
docker compose -f docker-compose.local.yaml exec app composer update
|
||||
```
|
||||
|
||||
### Database Commands
|
||||
|
||||
```bash
|
||||
# Connect to PostgreSQL (from inside container)
|
||||
docker compose -f docker-compose.local.yaml exec postgres psql -U teren_user -d teren_app
|
||||
|
||||
# Connect from Windows host
|
||||
psql -h localhost -p 5433 -U teren_user -d teren_app
|
||||
|
||||
# Backup database
|
||||
docker compose -f docker-compose.local.yaml exec postgres pg_dump -U teren_user teren_app > backup.sql
|
||||
|
||||
# Restore database
|
||||
docker compose -f docker-compose.local.yaml exec -T postgres psql -U teren_user teren_app < backup.sql
|
||||
|
||||
# Reset database
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan migrate:fresh --seed
|
||||
```
|
||||
|
||||
## pgAdmin Setup
|
||||
|
||||
1. Open http://localhost:5050
|
||||
2. Login: `admin@local.dev` / `admin`
|
||||
3. Add Server:
|
||||
- **General Tab:**
|
||||
- Name: `Teren Local`
|
||||
- **Connection Tab:**
|
||||
- Host: `postgres`
|
||||
- Port: `5432`
|
||||
- Database: `teren_app`
|
||||
- Username: `teren_user`
|
||||
- Passwo
|
||||
|
||||
**External Connection:** To connect from your Windows machine (e.g., DBeaver, pgAdmin desktop), use:
|
||||
- Host: `localhost`
|
||||
- Port: `5433` (not 5432)
|
||||
- Database: `teren_app`
|
||||
- Username: `teren_user`
|
||||
- Password: `local_password`rd: `local_password`
|
||||
4. Click Save
|
||||
|
||||
## Mailpit - Email Testing
|
||||
|
||||
All emails sent by the application are caught by Mailpit.
|
||||
|
||||
- Access: http://localhost:8025
|
||||
- View all emails in the web interface
|
||||
- Test email sending:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan tinker
|
||||
# In tinker:
|
||||
Mail::raw('Test email', function($msg) {
|
||||
$msg->to('test@example.com')->subject('Test');
|
||||
});
|
||||
```
|
||||
|
||||
## Portainer Setup
|
||||
|
||||
1. Open http://localhost:9000
|
||||
2. On first visit, create admin account
|
||||
3. Select "Docker" environment
|
||||
4. Click "Connect"
|
||||
|
||||
Use Portainer to:
|
||||
- View and manage containers
|
||||
- Check logs
|
||||
- Execute commands in containers
|
||||
- Monitor resource usage
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Frontend Development
|
||||
|
||||
The local setup supports live reloading:
|
||||
|
||||
```bash
|
||||
# Run Vite dev server (outside Docker)
|
||||
npm run dev
|
||||
|
||||
# Or inside Docker
|
||||
docker compose -f docker-compose.local.yaml exec app npm run dev
|
||||
```
|
||||
|
||||
Access: http://localhost:8080
|
||||
|
||||
### Code Changes
|
||||
|
||||
All code changes are automatically reflected because the source code is mounted as a volume:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./:/var/www # Live code mounting
|
||||
```
|
||||
|
||||
### Queue Workers
|
||||
|
||||
Queue workers are running via Supervisor inside the container. To restart:
|
||||
|
||||
```bash
|
||||
# Restart queue workers
|
||||
docker compose -f docker-compose.local.yaml exec app supervisorctl restart all
|
||||
|
||||
# Check status
|
||||
docker compose -f docker-compose.local.yaml exec app supervisorctl status
|
||||
|
||||
# View worker logs
|
||||
docker compose -f docker-compose.local.yaml exec app tail -f storage/logs/worker.log
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
If you get "port is already allocated" error:
|
||||
|
||||
```bash
|
||||
# Windows - Find process using port
|
||||
netstat -ano | findstr :8080
|
||||
|
||||
# Kill process by PID
|
||||
taskkill /PID <PID> /F
|
||||
|
||||
# Or change port in docker-compose.local.yaml
|
||||
ports:
|
||||
- "8081:80" # Change 8080 to 8081
|
||||
```
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose -f docker-compose.local.yaml logs app
|
||||
|
||||
# Rebuild containers
|
||||
docker compose -f docker-compose.local.yaml down
|
||||
docker compose -f docker-compose.local.yaml up -d --build
|
||||
```
|
||||
|
||||
### Permission Errors (Linux/Mac)
|
||||
|
||||
```bash
|
||||
# Fix storage permissions
|
||||
docker compose -f docker-compose.local.yaml exec app chown -R www:www /var/www/storage
|
||||
docker compose -f docker-compose.local.yaml exec app chmod -R 775 /var/www/storage
|
||||
```
|
||||
|
||||
### Database Connection Failed
|
||||
|
||||
```bash
|
||||
# Check if PostgreSQL is running
|
||||
docker compose -f docker-compose.local.yaml ps postgres
|
||||
|
||||
# Check logs
|
||||
docker compose -f docker-compose.local.yaml logs postgres
|
||||
|
||||
# Restart PostgreSQL
|
||||
docker compose -f docker-compose.local.yaml restart postgres
|
||||
```
|
||||
|
||||
### Clear All Data and Start Fresh
|
||||
|
||||
```bash
|
||||
# Stop and remove everything
|
||||
docker compose -f docker-compose.local.yaml down -v
|
||||
|
||||
# Remove images
|
||||
docker compose -f docker-compose.local.yaml down --rmi all
|
||||
|
||||
# Start fresh
|
||||
docker compose -f docker-compose.local.yaml up -d --build
|
||||
|
||||
# Re-initialize
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan key:generate
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan migrate:fresh --seed
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### Windows Performance
|
||||
|
||||
If using WSL2 (recommended):
|
||||
|
||||
1. Clone repo inside WSL2 filesystem, not Windows filesystem
|
||||
2. Use WSL2 terminal for commands
|
||||
3. Enable WSL2 integration in Docker Desktop settings
|
||||
|
||||
### Mac Performance
|
||||
|
||||
1. Enable VirtioFS in Docker Desktop settings
|
||||
2. Disable file watching if not needed
|
||||
3. Use Docker volumes for vendor directories:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
- /var/www/vendor # Anonymous volume for vendor
|
||||
- /var/www/node_modules # Anonymous volume for node_modules
|
||||
```
|
||||
|
||||
## Testing Production-Like Setup
|
||||
|
||||
To test the production VPN setup locally (advanced):
|
||||
|
||||
1. Enable WireGuard in `docker-compose.yaml.example`
|
||||
2. Change all `10.13.13.1` bindings to `127.0.0.1`
|
||||
3. Test SSL with self-signed certificates
|
||||
|
||||
## Differences from Production
|
||||
|
||||
| Feature | Local | Production |
|
||||
|---------|-------|------------|
|
||||
| **VPN** | No VPN | WireGuard required |
|
||||
| **Port** | :8080 | :80/:443 |
|
||||
| **SSL** | No SSL | Let's Encrypt |
|
||||
| **Debug** | Enabled | Disabled |
|
||||
| **Emails** | Mailpit | Real SMTP |
|
||||
| **Logs** | Debug level | Error level |
|
||||
| **Code** | Live mount | Built into image |
|
||||
|
||||
## Next Steps
|
||||
|
||||
After testing locally:
|
||||
|
||||
1. Review `docker-compose.yaml.example` for production
|
||||
2. Follow `DEPLOYMENT_GUIDE.md` for VPS setup
|
||||
3. Configure WireGuard VPN
|
||||
4. Deploy to production
|
||||
|
||||
## Useful Resources
|
||||
|
||||
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||
- [Laravel Docker Documentation](https://laravel.com/docs/deployment)
|
||||
- [PostgreSQL Docker](https://hub.docker.com/_/postgres)
|
||||
- [Mailpit Documentation](https://github.com/axllent/mailpit)
|
||||
@@ -0,0 +1,159 @@
|
||||
# Quick Start: VPN-Only Access Setup
|
||||
|
||||
⚠️ **IMPORTANT:** This application is configured for VPN-ONLY access. It will NOT be publicly accessible.
|
||||
|
||||
## Quick Setup Steps
|
||||
|
||||
### 1. Install Docker (on VPS)
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
### 2. Clone & Configure
|
||||
```bash
|
||||
git clone YOUR_GITEA_REPO/Teren-app.git
|
||||
cd Teren-app
|
||||
cp docker-compose.yaml.example docker-compose.yaml
|
||||
cp .env.production.example .env
|
||||
```
|
||||
|
||||
### 3. Edit Configuration
|
||||
```bash
|
||||
vim .env
|
||||
```
|
||||
|
||||
**Required changes:**
|
||||
- `WG_SERVERURL` = Your VPS public IP (e.g., `123.45.67.89`)
|
||||
- `WG_UI_PASSWORD` = Strong password for WireGuard dashboard
|
||||
- `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` = Database credentials
|
||||
- `PGADMIN_EMAIL`, `PGADMIN_PASSWORD` = pgAdmin credentials
|
||||
|
||||
### 4. Start WireGuard First
|
||||
```bash
|
||||
# Enable kernel module
|
||||
sudo modprobe wireguard
|
||||
|
||||
# Start WireGuard
|
||||
docker compose up -d wireguard
|
||||
|
||||
# Wait 10 seconds
|
||||
sleep 10
|
||||
|
||||
# Check status
|
||||
docker compose logs wireguard
|
||||
```
|
||||
|
||||
### 5. Setup VPN Client (on your laptop/desktop)
|
||||
|
||||
**Access WireGuard Dashboard:** `http://YOUR_VPS_IP:51821`
|
||||
|
||||
1. Login with password from step 3
|
||||
2. Click "New Client"
|
||||
3. Name it (e.g., "MyLaptop")
|
||||
4. Download config or scan QR code
|
||||
|
||||
**Install WireGuard Client:**
|
||||
- Windows: https://www.wireguard.com/install/
|
||||
- macOS: App Store
|
||||
- Linux: `sudo apt install wireguard`
|
||||
- Mobile: App Store / Play Store
|
||||
|
||||
**Import config and CONNECT**
|
||||
|
||||
### 6. Verify VPN Works
|
||||
```bash
|
||||
# From your local machine (while connected to VPN)
|
||||
ping 10.13.13.1
|
||||
```
|
||||
|
||||
Should get responses ✅
|
||||
|
||||
### 7. Secure WireGuard Dashboard
|
||||
|
||||
Edit `docker-compose.yaml`:
|
||||
```yaml
|
||||
# Find wireguard service, change:
|
||||
ports:
|
||||
- "51821:51821/tcp"
|
||||
# To:
|
||||
ports:
|
||||
- "10.13.13.1:51821:51821/tcp"
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d wireguard
|
||||
```
|
||||
|
||||
### 8. Start All Services
|
||||
```bash
|
||||
# Make sure you're connected to VPN!
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 9. Initialize Application
|
||||
```bash
|
||||
# Generate app key
|
||||
docker compose exec app php artisan key:generate
|
||||
|
||||
# Run migrations
|
||||
docker compose exec app php artisan migrate --force
|
||||
|
||||
# Cache config
|
||||
docker compose exec app php artisan config:cache
|
||||
```
|
||||
|
||||
### 10. Access Your Services
|
||||
|
||||
**While connected to VPN:**
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| **Laravel App** | http://10.13.13.1 |
|
||||
| **Portainer** | http://10.13.13.1:9000 |
|
||||
| **pgAdmin** | http://10.13.13.1:5050 |
|
||||
| **WireGuard UI** | http://10.13.13.1:51821 |
|
||||
|
||||
## Firewall Configuration
|
||||
|
||||
```bash
|
||||
sudo ufw allow 22/tcp # SSH
|
||||
sudo ufw allow 51820/udp # WireGuard VPN
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
**That's it!** ✅
|
||||
|
||||
## Adding More VPN Clients
|
||||
|
||||
1. Connect to VPN
|
||||
2. Open: `http://10.13.13.1:51821`
|
||||
3. Click "New Client"
|
||||
4. Download config
|
||||
5. Import on new device
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Can't connect to VPN:**
|
||||
```bash
|
||||
docker compose logs wireguard
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
**Can't access app after VPN connection:**
|
||||
```bash
|
||||
ping 10.13.13.1
|
||||
docker compose ps
|
||||
docker compose logs nginx
|
||||
```
|
||||
|
||||
**Check which ports are exposed:**
|
||||
```bash
|
||||
docker compose ps
|
||||
sudo netstat -tulpn | grep 10.13.13.1
|
||||
```
|
||||
|
||||
## Full Documentation
|
||||
|
||||
See `DEPLOYMENT_GUIDE.md` for complete setup instructions, SSL configuration, automated deployments, and troubleshooting.
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Contract;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use Maatwebsite\Excel\Concerns\WithCustomValueBinder;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
|
||||
class ClientContractsExport extends DefaultValueBinder implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithCustomValueBinder, WithHeadings, WithMapping
|
||||
{
|
||||
public const DATE_EXCEL_FORMAT = 'dd"."mm"."yyyy';
|
||||
|
||||
public const TEXT_EXCEL_FORMAT = NumberFormat::FORMAT_TEXT;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $columnLetterMap = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array{label: string}>
|
||||
*/
|
||||
public const COLUMN_METADATA = [
|
||||
'reference' => ['label' => 'Referenca'],
|
||||
'customer' => ['label' => 'Stranka'],
|
||||
'address' => ['label' => 'Naslov'],
|
||||
'start' => ['label' => 'Začetek'],
|
||||
'segment' => ['label' => 'Segment'],
|
||||
'balance' => ['label' => 'Stanje'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<int, string> $columns
|
||||
*/
|
||||
public function __construct(private Builder $query, private array $columns) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function allowedColumns(): array
|
||||
{
|
||||
return array_keys(self::COLUMN_METADATA);
|
||||
}
|
||||
|
||||
public static function columnLabel(string $column): string
|
||||
{
|
||||
return self::COLUMN_METADATA[$column]['label'] ?? $column;
|
||||
}
|
||||
|
||||
public function query(): Builder
|
||||
{
|
||||
return $this->query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public function map($row): array
|
||||
{
|
||||
return array_map(fn (string $column) => $this->resolveValue($row, $column), $this->columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function headings(): array
|
||||
{
|
||||
return array_map(fn (string $column) => self::columnLabel($column), $this->columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function columnFormats(): array
|
||||
{
|
||||
$formats = [];
|
||||
|
||||
foreach ($this->getColumnLetterMap() as $letter => $column) {
|
||||
if ($column === 'reference') {
|
||||
$formats[$letter] = self::TEXT_EXCEL_FORMAT;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($column === 'start') {
|
||||
$formats[$letter] = self::DATE_EXCEL_FORMAT;
|
||||
}
|
||||
}
|
||||
|
||||
return $formats;
|
||||
}
|
||||
|
||||
private function resolveValue(Contract $contract, string $column): mixed
|
||||
{
|
||||
return match ($column) {
|
||||
'reference' => $contract->reference,
|
||||
'customer' => optional($contract->clientCase?->person)->full_name,
|
||||
'address' => optional($contract->clientCase?->person?->address)->address,
|
||||
'start' => $this->formatDate($contract->start_date),
|
||||
'segment' => $contract->segments?->first()?->name,
|
||||
'balance' => optional($contract->account)->balance_amount,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function formatDate(?string $date): mixed
|
||||
{
|
||||
if (empty($date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$carbon = Carbon::parse($date);
|
||||
|
||||
return ExcelDate::dateTimeToExcel($carbon);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getColumnLetterMap(): array
|
||||
{
|
||||
if ($this->columnLetterMap !== []) {
|
||||
return $this->columnLetterMap;
|
||||
}
|
||||
|
||||
$letter = 'A';
|
||||
foreach ($this->columns as $column) {
|
||||
$this->columnLetterMap[$letter] = $column;
|
||||
$letter++;
|
||||
}
|
||||
|
||||
return $this->columnLetterMap;
|
||||
}
|
||||
|
||||
public function bindValue(Cell $cell, $value): bool
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
$cell->setValueExplicit($value, DataType::TYPE_NUMERIC);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return parent::bindValue($cell, $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreInstallmentRequest;
|
||||
use App\Models\Account;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Booking;
|
||||
use App\Models\Installment;
|
||||
use App\Models\InstallmentSetting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class AccountInstallmentController extends Controller
|
||||
{
|
||||
public function list(Account $account): JsonResponse
|
||||
{
|
||||
$installments = Installment::query()
|
||||
->where('account_id', $account->id)
|
||||
->orderByDesc('installment_at')
|
||||
->get(['id', 'amount', 'balance_before', 'currency', 'reference', 'installment_at', 'created_at'])
|
||||
->map(function (Installment $i) {
|
||||
return [
|
||||
'id' => $i->id,
|
||||
'amount' => (float) $i->amount,
|
||||
'balance_before' => (float) ($i->balance_before ?? 0),
|
||||
'currency' => $i->currency,
|
||||
'reference' => $i->reference,
|
||||
'installment_at' => optional($i->installment_at)?->toDateString(),
|
||||
'created_at' => optional($i->created_at)?->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'account' => [
|
||||
'id' => $account->id,
|
||||
'balance_amount' => $account->balance_amount,
|
||||
],
|
||||
'installments' => $installments,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreInstallmentRequest $request, Account $account): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$amountCents = (int) round(((float) $validated['amount']) * 100);
|
||||
|
||||
$settings = InstallmentSetting::query()->first();
|
||||
$defaultCurrency = strtoupper($settings->default_currency ?? 'EUR');
|
||||
|
||||
$installment = Installment::query()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_before' => (float) ($account->balance_amount ?? 0),
|
||||
'amount' => (float) $validated['amount'],
|
||||
'currency' => strtoupper($validated['currency'] ?? $defaultCurrency),
|
||||
'reference' => $validated['reference'] ?? null,
|
||||
'installment_at' => $validated['installment_at'] ?? now(),
|
||||
'meta' => $validated['meta'] ?? null,
|
||||
'created_by' => $request->user()?->id,
|
||||
]);
|
||||
|
||||
// Debit booking — increases the account balance
|
||||
Booking::query()->create([
|
||||
'account_id' => $account->id,
|
||||
'payment_id' => null,
|
||||
'amount_cents' => $amountCents,
|
||||
'type' => 'debit',
|
||||
'description' => $installment->reference ? ('Obremenitev '.$installment->reference) : 'Obremenitev',
|
||||
'booked_at' => $installment->installment_at ?? now(),
|
||||
]);
|
||||
|
||||
if ($settings && ($settings->create_activity_on_installment ?? false)) {
|
||||
$note = $settings->activity_note_template ?? 'Dodan obrok';
|
||||
$note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $installment->currency], $note);
|
||||
|
||||
$account->refresh();
|
||||
$beforeStr = number_format((float) ($installment->balance_before ?? 0), 2, ',', '.').' '.$installment->currency;
|
||||
$afterStr = number_format((float) ($account->balance_amount ?? 0), 2, ',', '.').' '.$installment->currency;
|
||||
$note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: obrok)";
|
||||
|
||||
$account->loadMissing('contract');
|
||||
$clientCaseId = $account->contract?->client_case_id;
|
||||
if ($clientCaseId) {
|
||||
$activity = Activity::query()->create([
|
||||
'due_date' => null,
|
||||
'amount' => $amountCents / 100,
|
||||
'note' => $note,
|
||||
'action_id' => $settings->default_action_id,
|
||||
'decision_id' => $settings->default_decision_id,
|
||||
'client_case_id' => $clientCaseId,
|
||||
'contract_id' => $account->contract_id,
|
||||
]);
|
||||
$installment->update(['activity_id' => $activity->id]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'Installment created.');
|
||||
}
|
||||
|
||||
public function destroy(Account $account, Installment $installment): RedirectResponse|JsonResponse
|
||||
{
|
||||
if ($installment->account_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Delete related debit booking(s) to revert balance via model events
|
||||
Booking::query()
|
||||
->where('account_id', $account->id)
|
||||
->where('type', 'debit')
|
||||
->whereDate('booked_at', optional($installment->installment_at)?->toDateString())
|
||||
->where('amount_cents', (int) round(((float) $installment->amount) * 100))
|
||||
->whereNull('payment_id')
|
||||
->get()
|
||||
->each->delete();
|
||||
|
||||
if ($installment->activity_id) {
|
||||
$activity = Activity::query()->find($installment->activity_id);
|
||||
if ($activity) {
|
||||
$activity->delete();
|
||||
}
|
||||
}
|
||||
|
||||
$installment->delete();
|
||||
|
||||
if (request()->wantsJson()) {
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Installment deleted.');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailTemplate;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -69,4 +70,15 @@ public function show(EmailLog $emailLog): Response
|
||||
'log' => $emailLog,
|
||||
]);
|
||||
}
|
||||
|
||||
public function body(EmailLog $emailLog): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', EmailTemplate::class);
|
||||
|
||||
$emailLog->load('body');
|
||||
|
||||
return response()->json([
|
||||
'html' => $emailLog->body?->body_html ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailLogStatus;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\MailProfile;
|
||||
use App\Services\EmailTemplateRenderer;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -55,8 +56,14 @@ public function create(): Response
|
||||
{
|
||||
$this->authorize('create', EmailTemplate::class);
|
||||
|
||||
$actions = \App\Models\Action::query()
|
||||
->with(['decisions:id,name'])
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
return Inertia::render('Admin/EmailTemplates/Edit', [
|
||||
'template' => null,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -93,7 +100,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
|
||||
// Context resolution (shared logic with renderFinalHtml)
|
||||
$ctx = [];
|
||||
if ($id = $request->integer('activity_id')) {
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
|
||||
if ($activity) {
|
||||
$ctx['activity'] = $activity;
|
||||
// Derive base entities from activity when not explicitly provided
|
||||
@@ -110,7 +117,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
|
||||
}
|
||||
}
|
||||
if ($id = $request->integer('contract_id')) {
|
||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
|
||||
if ($contract) {
|
||||
$ctx['contract'] = $contract;
|
||||
if ($contract->clientCase) {
|
||||
@@ -140,6 +147,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
|
||||
}
|
||||
}
|
||||
$ctx['extra'] = (array) $request->input('extra', []);
|
||||
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
|
||||
|
||||
$rendered = $renderer->render([
|
||||
'subject' => $subject,
|
||||
@@ -161,8 +169,14 @@ public function edit(EmailTemplate $emailTemplate): Response
|
||||
$q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']);
|
||||
}]);
|
||||
|
||||
$actions = \App\Models\Action::query()
|
||||
->with(['decisions:id,name'])
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
return Inertia::render('Admin/EmailTemplates/Edit', [
|
||||
'template' => $emailTemplate,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -181,7 +195,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
||||
// Context resolution
|
||||
$ctx = [];
|
||||
if ($id = $request->integer('activity_id')) {
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
|
||||
if ($activity) {
|
||||
$ctx['activity'] = $activity;
|
||||
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||
@@ -197,7 +211,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
||||
}
|
||||
}
|
||||
if ($id = $request->integer('contract_id')) {
|
||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
|
||||
if ($contract) {
|
||||
$ctx['contract'] = $contract;
|
||||
if ($contract->clientCase) {
|
||||
@@ -227,6 +241,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
||||
}
|
||||
}
|
||||
$ctx['extra'] = (array) $request->input('extra', []);
|
||||
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
|
||||
|
||||
// Render preview values; we store a minimal snapshot on the log
|
||||
$rendered = $renderer->render([
|
||||
@@ -293,7 +308,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
||||
// Context resolution (same as sendTest)
|
||||
$ctx = [];
|
||||
if ($id = $request->integer('activity_id')) {
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
|
||||
if ($activity) {
|
||||
$ctx['activity'] = $activity;
|
||||
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||
@@ -309,7 +324,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
||||
}
|
||||
}
|
||||
if ($id = $request->integer('contract_id')) {
|
||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
|
||||
if ($contract) {
|
||||
$ctx['contract'] = $contract;
|
||||
if ($contract->clientCase) {
|
||||
@@ -339,6 +354,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
||||
}
|
||||
}
|
||||
$ctx['extra'] = (array) $request->input('extra', []);
|
||||
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
|
||||
|
||||
$rendered = $renderer->render([
|
||||
'subject' => $subject,
|
||||
|
||||
@@ -26,7 +26,7 @@ public function index(): Response
|
||||
->orderBy('priority')
|
||||
->orderBy('id')
|
||||
->get([
|
||||
'id', 'name', 'active', 'host', 'port', 'encryption', 'from_address', 'priority', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at',
|
||||
'id', 'name', 'active', 'auto_mailer', 'host', 'port', 'username', 'from_name', 'encryption', 'from_address', 'priority', 'signature', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at',
|
||||
]);
|
||||
|
||||
return Inertia::render('Admin/MailProfiles/Index', [
|
||||
@@ -76,6 +76,15 @@ public function toggle(Request $request, MailProfile $mailProfile)
|
||||
return back()->with('success', 'Status updated');
|
||||
}
|
||||
|
||||
public function toggleAutoMailer(Request $request, MailProfile $mailProfile)
|
||||
{
|
||||
$this->authorize('update', $mailProfile);
|
||||
$mailProfile->auto_mailer = ! $mailProfile->auto_mailer;
|
||||
$mailProfile->save();
|
||||
|
||||
return back()->with('success', 'Auto-mailer updated');
|
||||
}
|
||||
|
||||
public function test(Request $request, MailProfile $mailProfile)
|
||||
{
|
||||
$this->authorize('test', $mailProfile);
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreEmailPackageFromContractsRequest;
|
||||
use App\Http\Requests\StorePackageFromContractsRequest;
|
||||
use App\Http\Requests\StorePackageRequest;
|
||||
use App\Jobs\PackageItemEmailJob;
|
||||
use App\Jobs\PackageItemSmsJob;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackageItem;
|
||||
use App\Models\SmsTemplate;
|
||||
use App\Services\Contact\EmailSelector;
|
||||
use App\Services\Contact\PhoneSelector;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -21,18 +24,40 @@
|
||||
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
public function landing(): Response
|
||||
{
|
||||
$packages = Package::query()
|
||||
->latest('id')
|
||||
->paginate(25);
|
||||
return Inertia::render('Packages/Index');
|
||||
}
|
||||
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
public function smsIndex(Request $request): Response
|
||||
{
|
||||
$perPage = $request->input('per_page') ?? 25;
|
||||
|
||||
$packages = Package::query()
|
||||
->where('type', Package::TYPE_SMS)
|
||||
->latest('id')
|
||||
->paginate($perPage);
|
||||
|
||||
return Inertia::render('Packages/Sms/Index', [
|
||||
'packages' => $packages,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
public function emailIndex(Request $request): Response
|
||||
{
|
||||
$perPage = $request->input('per_page') ?? 25;
|
||||
|
||||
$packages = Package::query()
|
||||
->where('type', Package::TYPE_EMAIL)
|
||||
->latest('id')
|
||||
->paginate($perPage);
|
||||
|
||||
return Inertia::render('Packages/Mail/Index', [
|
||||
'packages' => $packages,
|
||||
]);
|
||||
}
|
||||
|
||||
public function smsCreate(Request $request): Response
|
||||
{
|
||||
// Minimal lookups for create form (active only)
|
||||
$profiles = \App\Models\SmsProfile::query()
|
||||
@@ -48,6 +73,7 @@ public function create(Request $request): Response
|
||||
->get(['id', 'name', 'content']);
|
||||
$segments = \App\Models\Segment::query()
|
||||
->where('active', true)
|
||||
->where('exclude', false)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
// Provide a lightweight list of recent clients with person names for filtering
|
||||
@@ -66,7 +92,7 @@ public function create(Request $request): Response
|
||||
})
|
||||
->values();
|
||||
|
||||
return Inertia::render('Admin/Packages/Create', [
|
||||
return Inertia::render('Packages/Sms/Create', [
|
||||
'profiles' => $profiles,
|
||||
'senders' => $senders,
|
||||
'templates' => $templates,
|
||||
@@ -75,7 +101,53 @@ public function create(Request $request): Response
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Package $package, SmsService $sms): Response
|
||||
public function emailCreate(): Response
|
||||
{
|
||||
$emailTemplates = \App\Models\EmailTemplate::query()
|
||||
->where('active', true)
|
||||
->where('client', false)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'subject_template', 'text_template', 'html_template'])
|
||||
->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'name' => $t->name,
|
||||
'subject_template' => $t->subject_template,
|
||||
'text_template' => $t->text_template,
|
||||
'has_body_text' => (bool) preg_match('/{{\s*body_text\s*}}/', (string) $t->html_template),
|
||||
])->values();
|
||||
$mailProfiles = \App\Models\MailProfile::query()
|
||||
->where('active', true)
|
||||
->orderBy('priority')
|
||||
->get(['id', 'name']);
|
||||
$segments = \App\Models\Segment::query()
|
||||
->where('active', true)
|
||||
->where('exclude', false)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
$clients = \App\Models\Client::query()
|
||||
->with(['person' => function ($q) {
|
||||
$q->select('id', 'uuid', 'full_name');
|
||||
}])
|
||||
->latest('id')
|
||||
->get(['id', 'uuid', 'person_id'])
|
||||
->map(function ($c) {
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'uuid' => $c->uuid,
|
||||
'name' => $c->person?->full_name ?? ('Client #'.$c->id),
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
return Inertia::render('Packages/Mail/Create', [
|
||||
'emailTemplates' => $emailTemplates,
|
||||
'mailProfiles' => $mailProfiles,
|
||||
'segments' => $segments,
|
||||
'clients' => $clients,
|
||||
]);
|
||||
}
|
||||
|
||||
public function smsShow(Package $package, SmsService $sms): Response
|
||||
{
|
||||
$items = $package->items()->latest('id')->paginate(25);
|
||||
|
||||
@@ -209,13 +281,23 @@ public function show(Package $package, SmsService $sms): Response
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('Admin/Packages/Show', [
|
||||
return Inertia::render('Packages/Sms/Show', [
|
||||
'package' => $package,
|
||||
'items' => $items,
|
||||
'preview' => $preview,
|
||||
]);
|
||||
}
|
||||
|
||||
public function emailShow(Package $package): Response
|
||||
{
|
||||
$items = $package->items()->latest('id')->paginate(25);
|
||||
|
||||
return Inertia::render('Packages/Mail/Show', [
|
||||
'package' => $package,
|
||||
'items' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StorePackageRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
@@ -257,7 +339,11 @@ public function dispatch(Package $package): RedirectResponse
|
||||
return back()->with('error', 'Package not in a dispatchable state.');
|
||||
}
|
||||
|
||||
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) {
|
||||
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) use ($package) {
|
||||
if ($package->type === Package::TYPE_EMAIL) {
|
||||
return new PackageItemEmailJob($item->id);
|
||||
}
|
||||
|
||||
return new PackageItemSmsJob($item->id);
|
||||
})->all();
|
||||
|
||||
@@ -283,7 +369,7 @@ public function dispatch(Package $package): RedirectResponse
|
||||
$package->save();
|
||||
}
|
||||
})
|
||||
->onQueue('sms')
|
||||
->onQueue($package->type === Package::TYPE_EMAIL ? 'email' : 'sms')
|
||||
->dispatch();
|
||||
|
||||
return back()->with('success', 'Package dispatched');
|
||||
@@ -319,7 +405,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
$request->validate([
|
||||
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||
'q' => ['nullable', 'string'],
|
||||
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
'only_mobile' => ['nullable', 'boolean'],
|
||||
'only_validated' => ['nullable', 'boolean'],
|
||||
@@ -330,13 +415,13 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
]);
|
||||
|
||||
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||
|
||||
|
||||
$query = Contract::query()
|
||||
->with([
|
||||
'clientCase.person.phones',
|
||||
'clientCase.client.person',
|
||||
'account',
|
||||
'segments:id,name',
|
||||
])
|
||||
->select('contracts.*')
|
||||
->latest('contracts.id');
|
||||
@@ -348,6 +433,15 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
->where('contract_segment.segment_id', '=', $segmentId)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
} else {
|
||||
// Only include contracts that have at least one active, non-excluded segment
|
||||
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
|
||||
->from('contract_segment')
|
||||
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
|
||||
->where('contract_segment.active', true)
|
||||
->where('segments.exclude', false)
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
);
|
||||
}
|
||||
|
||||
if ($q = trim((string) $request->input('q'))) {
|
||||
@@ -397,13 +491,14 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
});
|
||||
}
|
||||
|
||||
$contracts = $query->get();
|
||||
$contracts = $query->limit(500)->get();
|
||||
|
||||
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
|
||||
$person = $contract->clientCase?->person;
|
||||
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
||||
$phone = $selected['phone'];
|
||||
$clientPerson = $contract->clientCase?->client?->person;
|
||||
$segment = collect($contract->segments)->last();
|
||||
|
||||
return [
|
||||
'id' => $contract->id,
|
||||
@@ -421,6 +516,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
'uuid' => $person?->uuid,
|
||||
'full_name' => $person?->full_name,
|
||||
],
|
||||
'segment' => $segment,
|
||||
// Stranka: the client person
|
||||
'client' => $clientPerson ? [
|
||||
'id' => $contract->clientCase?->client?->id,
|
||||
@@ -432,13 +528,14 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
'number' => $phone->nu,
|
||||
'validated' => $phone->validated,
|
||||
'type' => $phone->phone_type?->value,
|
||||
'description' => $phone->description,
|
||||
] : null,
|
||||
'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $data
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -528,6 +625,213 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
|
||||
return back()->with('success', 'Package created from contracts');
|
||||
}
|
||||
|
||||
/**
|
||||
* List contracts with selected email per person (for email packages).
|
||||
*/
|
||||
public function contractsForEmail(Request $request, EmailSelector $selector): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||
'q' => ['nullable', 'string'],
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
'only_verified' => ['nullable', 'boolean'],
|
||||
'only_with_email' => ['nullable', 'boolean'],
|
||||
'start_date_from' => ['nullable', 'date'],
|
||||
'start_date_to' => ['nullable', 'date'],
|
||||
'promise_date_from' => ['nullable', 'date'],
|
||||
'promise_date_to' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||
|
||||
$query = Contract::query()
|
||||
->with([
|
||||
'clientCase.person.emails',
|
||||
'clientCase.client.person',
|
||||
'account',
|
||||
'segments:id,name',
|
||||
])
|
||||
->select('contracts.*')
|
||||
->latest('contracts.id');
|
||||
|
||||
if ($segmentId) {
|
||||
$query->join('contract_segment', function ($j) use ($segmentId) {
|
||||
$j->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||
->where('contract_segment.segment_id', '=', $segmentId)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
} else {
|
||||
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
|
||||
->from('contract_segment')
|
||||
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
|
||||
->where('contract_segment.active', true)
|
||||
->where('segments.exclude', false)
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
);
|
||||
}
|
||||
|
||||
if ($q = trim((string) $request->input('q'))) {
|
||||
$query->where('contracts.reference', 'ILIKE', "%{$q}%");
|
||||
}
|
||||
|
||||
if ($clientId = $request->integer('client_id')) {
|
||||
$query->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
|
||||
->where('client_cases.client_id', $clientId);
|
||||
}
|
||||
|
||||
if ($startDateFrom = $request->input('start_date_from')) {
|
||||
$query->where('contracts.start_date', '>=', $startDateFrom);
|
||||
}
|
||||
|
||||
if ($startDateTo = $request->input('start_date_to')) {
|
||||
$query->where('contracts.start_date', '<=', $startDateTo);
|
||||
}
|
||||
|
||||
$promiseDateFrom = $request->input('promise_date_from');
|
||||
$promiseDateTo = $request->input('promise_date_to');
|
||||
|
||||
if ($promiseDateFrom || $promiseDateTo) {
|
||||
$query->whereHas('account', function ($q) use ($promiseDateFrom, $promiseDateTo) {
|
||||
if ($promiseDateFrom) {
|
||||
$q->where('promise_date', '>=', $promiseDateFrom);
|
||||
}
|
||||
if ($promiseDateTo) {
|
||||
$q->where('promise_date', '<=', $promiseDateTo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->boolean('only_verified')) {
|
||||
$query->whereHas('clientCase.person.emails', function ($q) {
|
||||
$q->where('is_active', true)->whereNotNull('verified_at');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->boolean('only_with_email')) {
|
||||
$query->whereHas('clientCase.person.emails', function ($q) {
|
||||
$q->where('is_active', true);
|
||||
});
|
||||
}
|
||||
|
||||
$contracts = $query->limit(500)->get();
|
||||
|
||||
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
|
||||
$person = $contract->clientCase?->person;
|
||||
$selected = $person ? $selector->selectForPerson($person) : ['email' => null, 'reason' => 'no_person'];
|
||||
$email = $selected['email'];
|
||||
$clientPerson = $contract->clientCase?->client?->person;
|
||||
$segment = collect($contract->segments)->last();
|
||||
|
||||
return [
|
||||
'id' => $contract->id,
|
||||
'uuid' => $contract->uuid,
|
||||
'reference' => $contract->reference,
|
||||
'start_date' => $contract->start_date,
|
||||
'promise_date' => $contract->account?->promise_date,
|
||||
'case' => [
|
||||
'id' => $contract->clientCase?->id,
|
||||
'uuid' => $contract->clientCase?->uuid,
|
||||
],
|
||||
'person' => [
|
||||
'id' => $person?->id,
|
||||
'uuid' => $person?->uuid,
|
||||
'full_name' => $person?->full_name,
|
||||
],
|
||||
'segment' => $segment,
|
||||
'client' => $clientPerson ? [
|
||||
'id' => $contract->clientCase?->client?->id,
|
||||
'uuid' => $contract->clientCase?->client?->uuid,
|
||||
'name' => $clientPerson->full_name,
|
||||
] : null,
|
||||
'selected_email' => $email ? [
|
||||
'id' => $email->id,
|
||||
'value' => $email->value,
|
||||
'is_primary' => $email->is_primary,
|
||||
'verified' => $email->verified_at !== null,
|
||||
'label' => $email->label,
|
||||
] : null,
|
||||
'no_email_reason' => $email ? null : ($selected['reason'] ?? 'unknown'),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an email package from a list of contracts by selecting recipient emails.
|
||||
*/
|
||||
public function storeEmailFromContracts(StoreEmailPackageFromContractsRequest $request, EmailSelector $selector): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
$contracts = Contract::query()
|
||||
->with(['clientCase.person', 'account.type'])
|
||||
->whereIn('id', $data['contract_ids'])
|
||||
->get();
|
||||
|
||||
$items = [];
|
||||
$skipped = 0;
|
||||
foreach ($contracts as $contract) {
|
||||
$person = $contract->clientCase?->person;
|
||||
if (! $person) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$selected = $selector->selectForPerson($person);
|
||||
/** @var ?\App\Models\Email $email */
|
||||
$email = $selected['email'];
|
||||
if (! $email) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'email' => $email->value,
|
||||
'email_id' => $email->id,
|
||||
'payload' => $data['payload'] ?? [],
|
||||
'contract_id' => $contract->id,
|
||||
'account_id' => $contract->account?->id,
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($items)) {
|
||||
return back()->with('error', 'No recipients found for selected contracts.');
|
||||
}
|
||||
|
||||
$package = Package::query()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'type' => Package::TYPE_EMAIL,
|
||||
'status' => Package::STATUS_DRAFT,
|
||||
'name' => $data['name'] ?? null,
|
||||
'description' => $data['description'] ?? null,
|
||||
'meta' => array_merge($data['meta'] ?? [], [
|
||||
'source' => 'contracts',
|
||||
'skipped' => $skipped,
|
||||
]),
|
||||
'created_by' => optional($request->user())->id,
|
||||
]);
|
||||
|
||||
$packageItems = collect($items)->map(function (array $row) {
|
||||
return new PackageItem([
|
||||
'status' => 'queued',
|
||||
'target_json' => [
|
||||
'email' => $row['email'],
|
||||
'email_id' => $row['email_id'],
|
||||
'contract_id' => $row['contract_id'] ?? null,
|
||||
'account_id' => $row['account_id'] ?? null,
|
||||
],
|
||||
'payload_json' => $row['payload'] ?? [],
|
||||
]);
|
||||
});
|
||||
|
||||
$package->items()->saveMany($packageItems);
|
||||
$package->total_items = $packageItems->count();
|
||||
$package->save();
|
||||
|
||||
return back()->with('success', 'Email package created from contracts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten nested meta structure into dot-notation key-value pairs.
|
||||
* Extracts 'value' from objects with {title, value, type} structure.
|
||||
|
||||
@@ -20,7 +20,7 @@ public function index(Request $request): Response
|
||||
{
|
||||
Gate::authorize('manage-settings');
|
||||
|
||||
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active']);
|
||||
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active', 'login_redirect']);
|
||||
$roles = Role::with('permissions:id,slug,name')->orderBy('name')->get(['id', 'name', 'slug']);
|
||||
$permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']);
|
||||
|
||||
@@ -73,4 +73,17 @@ public function toggleActive(User $user): RedirectResponse
|
||||
|
||||
return back()->with('success', "Uporabnik {$status}");
|
||||
}
|
||||
|
||||
public function updateSettings(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
Gate::authorize('manage-settings');
|
||||
|
||||
$validated = $request->validate([
|
||||
'login_redirect' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$user->update($validated);
|
||||
|
||||
return back()->with('success', 'Nastavitve shranjene');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CallLater;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class CallLaterController extends Controller
|
||||
{
|
||||
public function index(Request $request): \Inertia\Response
|
||||
{
|
||||
$query = CallLater::query()
|
||||
->with([
|
||||
'clientCase.person',
|
||||
'contract',
|
||||
'user',
|
||||
'activity',
|
||||
])
|
||||
->whereNull('completed_at')
|
||||
->orderBy('call_back_at', 'asc');
|
||||
|
||||
if ($request->filled('date_from')) {
|
||||
$query->whereDate('call_back_at', '>=', $request->date_from);
|
||||
}
|
||||
if ($request->filled('date_to')) {
|
||||
$query->whereDate('call_back_at', '<=', $request->date_to);
|
||||
}
|
||||
if ($request->filled('search')) {
|
||||
$term = '%'.$request->search.'%';
|
||||
$query->whereHas('clientCase.person', function ($q) use ($term) {
|
||||
$q->where('first_name', 'ilike', $term)
|
||||
->orWhere('last_name', 'ilike', $term)
|
||||
->orWhere('full_name', 'ilike', $term)
|
||||
->orWhereRaw("CONCAT(first_name, ' ', last_name) ILIKE ?", [$term]);
|
||||
});
|
||||
}
|
||||
|
||||
$callLaters = $query->paginate(50)->withQueryString();
|
||||
|
||||
return Inertia::render('CallLaters/Index', [
|
||||
'callLaters' => $callLaters,
|
||||
'filters' => $request->only(['date_from', 'date_to', 'search']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function complete(CallLater $callLater): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$callLater->update(['completed_at' => now()]);
|
||||
|
||||
return back()->with('success', 'Klic označen kot opravljen.');
|
||||
}
|
||||
}
|
||||
@@ -71,10 +71,8 @@ public function index(ClientCase $clientCase, Request $request)
|
||||
$que->whereDate('client_cases.created_at', '<=', $to);
|
||||
})
|
||||
->groupBy('client_cases.id')
|
||||
->addSelect([
|
||||
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
|
||||
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
])
|
||||
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
|
||||
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
|
||||
->with(['person.client', 'client.person'])
|
||||
->orderByDesc('client_cases.created_at');
|
||||
|
||||
@@ -223,7 +221,11 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
|
||||
return back()->with('warning', __('contracts.edit_not_allowed_archived'));
|
||||
}
|
||||
|
||||
\DB::transaction(function () use ($request, $contract) {
|
||||
$balanceChanged = false;
|
||||
$oldBalance = null;
|
||||
$newBalance = null;
|
||||
|
||||
\DB::transaction(function () use ($request, $contract, &$balanceChanged, &$oldBalance, &$newBalance) {
|
||||
$contract->update([
|
||||
'reference' => $request->input('reference'),
|
||||
'type_id' => $request->input('type_id'),
|
||||
@@ -254,6 +256,7 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
|
||||
$accountData['type_id'] = $request->input('account_type_id');
|
||||
}
|
||||
if ($currentAccount) {
|
||||
$oldBalance = (float) $currentAccount->balance_amount;
|
||||
$currentAccount->update($accountData);
|
||||
if (array_key_exists('balance_amount', $accountData)) {
|
||||
$currentAccount->forceFill(['balance_amount' => $accountData['balance_amount']])->save();
|
||||
@@ -264,6 +267,10 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
|
||||
->update(['balance_amount' => $accountData['balance_amount'], 'updated_at' => now()]);
|
||||
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
||||
}
|
||||
$newBalance = $freshBal;
|
||||
if ($oldBalance !== $freshBal) {
|
||||
$balanceChanged = true;
|
||||
}
|
||||
} else {
|
||||
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
||||
}
|
||||
@@ -276,6 +283,27 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
|
||||
|
||||
});
|
||||
|
||||
// Fire activity if balance changed and settings require it
|
||||
if ($balanceChanged) {
|
||||
$contractSetting = \App\Models\ContractSetting::query()->first();
|
||||
if ($contractSetting && $contractSetting->create_activity_on_balance_change) {
|
||||
$note = str_replace(
|
||||
['{old_balance}', '{new_balance}', '{currency}'],
|
||||
[number_format($oldBalance, 2, '.', ''), number_format($newBalance, 2, '.', ''), 'EUR'],
|
||||
$contractSetting->activity_note_template ?? ''
|
||||
);
|
||||
\App\Models\Activity::query()->create([
|
||||
'due_date' => null,
|
||||
'amount' => $newBalance,
|
||||
'note' => $note,
|
||||
'action_id' => $contractSetting->default_action_id,
|
||||
'decision_id' => $contractSetting->default_decision_id,
|
||||
'client_case_id' => $contract->client_case_id,
|
||||
'contract_id' => $contract->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve segment filter if present
|
||||
$segment = request('segment');
|
||||
|
||||
@@ -306,6 +334,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
try {
|
||||
$attributes = $request->validate([
|
||||
'due_date' => 'nullable|date',
|
||||
'call_back_at' => 'nullable|date_format:Y-m-d H:i:s|after_or_equal:now',
|
||||
'amount' => 'nullable|decimal:0,4',
|
||||
'note' => 'nullable|string',
|
||||
'action_id' => 'exists:\App\Models\Action,id',
|
||||
@@ -326,14 +355,14 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
|
||||
// Determine which contracts to process
|
||||
$contractIds = [];
|
||||
if ($createForAll && !empty($contractUuids)) {
|
||||
if ($createForAll && ! empty($contractUuids)) {
|
||||
// Get all contract IDs from the provided UUIDs
|
||||
$contracts = Contract::withTrashed()
|
||||
->whereIn('uuid', $contractUuids)
|
||||
->where('client_case_id', $clientCase->id)
|
||||
->get();
|
||||
$contractIds = $contracts->pluck('id')->toArray();
|
||||
} elseif (!empty($contractUuids) && isset($contractUuids[0])) {
|
||||
} elseif (! empty($contractUuids) && isset($contractUuids[0])) {
|
||||
// Single contract mode
|
||||
$contract = Contract::withTrashed()
|
||||
->where('uuid', $contractUuids[0])
|
||||
@@ -342,7 +371,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
if ($contract) {
|
||||
$contractIds = [$contract->id];
|
||||
}
|
||||
} elseif (!empty($attributes['contract_uuid'])) {
|
||||
} elseif (! empty($attributes['contract_uuid'])) {
|
||||
// Legacy single contract_uuid support
|
||||
$contract = Contract::withTrashed()
|
||||
->where('uuid', $attributes['contract_uuid'])
|
||||
@@ -360,7 +389,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
|
||||
$createdActivities = [];
|
||||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
||||
|
||||
|
||||
// Disable auto mail if creating activities for multiple contracts
|
||||
if ($sendFlag && count($contractIds) > 1) {
|
||||
$sendFlag = false;
|
||||
@@ -371,6 +400,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
// Create activity
|
||||
$row = $clientCase->activities()->create([
|
||||
'due_date' => $attributes['due_date'] ?? null,
|
||||
'call_back_at' => $attributes['call_back_at'] ?? null,
|
||||
'amount' => $attributes['amount'] ?? null,
|
||||
'note' => $attributes['note'] ?? null,
|
||||
'action_id' => $attributes['action_id'],
|
||||
@@ -417,29 +447,29 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
->whereIn('id', $attachmentIds)
|
||||
->pluck('id');
|
||||
$validAttachmentIds = Document::query()
|
||||
->where('documentable_type', Contract::class)
|
||||
->where('documentable_id', $contractId)
|
||||
->whereIn('id', $attachmentIds)
|
||||
->pluck('id');
|
||||
->where('documentable_type', Contract::class)
|
||||
->where('documentable_id', $contractId)
|
||||
->whereIn('id', $attachmentIds)
|
||||
->pluck('id');
|
||||
}
|
||||
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [
|
||||
'attachment_ids' => $validAttachmentIds->all(),
|
||||
]);
|
||||
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
||||
// If template requires contract and user attempted to send, surface a validation message
|
||||
logger()->warning('Email not queued: required contract is missing for the selected template.');
|
||||
}
|
||||
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
|
||||
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Do not fail activity creation due to mailing issues
|
||||
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
|
||||
}
|
||||
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [
|
||||
'attachment_ids' => $validAttachmentIds->all(),
|
||||
]);
|
||||
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
||||
// If template requires contract and user attempted to send, surface a validation message
|
||||
logger()->warning('Email not queued: required contract is missing for the selected template.');
|
||||
}
|
||||
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
|
||||
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Do not fail activity creation due to mailing issues
|
||||
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$activityCount = count($createdActivities);
|
||||
$successMessage = $activityCount > 1
|
||||
$successMessage = $activityCount > 1
|
||||
? "Successfully created {$activityCount} activities!"
|
||||
: 'Successfully created activity!';
|
||||
|
||||
@@ -602,9 +632,9 @@ public function storeDocument(ClientCase $clientCase, Request $request)
|
||||
$contract = null;
|
||||
if (! empty($validated['contract_uuid'])) {
|
||||
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
|
||||
if ($contract && ! $contract->active) {
|
||||
/*if ($contract && ! $contract->active) {
|
||||
return back()->with('warning', __('contracts.document_not_allowed_archived'));
|
||||
}
|
||||
}*/
|
||||
}
|
||||
$directory = $contract
|
||||
? ('contracts/'.$contract->uuid.'/documents')
|
||||
@@ -825,9 +855,8 @@ public function show(ClientCase $clientCase)
|
||||
}
|
||||
|
||||
// Get contracts using service
|
||||
$contractsPerPage = request()->integer('contracts_per_page', 10);
|
||||
$contracts = $this->caseDataService->getContracts($case, $segmentId, $contractsPerPage);
|
||||
$contractIds = collect($contracts->items())->pluck('id')->all();
|
||||
$contracts = $this->caseDataService->getContracts($case, $segmentId);
|
||||
$contractIds = collect($contracts)->pluck('id')->all();
|
||||
|
||||
// Get activities using service
|
||||
$activitiesPerPage = request()->integer('activities_per_page', 15);
|
||||
@@ -868,11 +897,14 @@ public function show(ClientCase $clientCase)
|
||||
'decisions.emailTemplate' => function ($q) {
|
||||
$q->select('id', 'name', 'entity_types', 'allow_attachments');
|
||||
},
|
||||
'decisions.events' => function ($q) {
|
||||
$q->select('events.id', 'events.key', 'events.name');
|
||||
},
|
||||
])
|
||||
->get(['id', 'name', 'color_tag', 'segment_id']),
|
||||
'types' => $types,
|
||||
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
|
||||
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
|
||||
'all_segments' => Segment::query()->where('active', true)->get(['id', 'name']),
|
||||
'current_segment' => $currentSegment,
|
||||
'sms_profiles' => \App\Models\SmsProfile::query()
|
||||
->select(['id', 'name', 'default_sender_id'])
|
||||
@@ -881,14 +913,27 @@ public function show(ClientCase $clientCase)
|
||||
->get(),
|
||||
'sms_senders' => \App\Models\SmsSender::query()
|
||||
->select(['id', 'profile_id'])
|
||||
->addSelect(\DB::raw('sname as name'))
|
||||
->addSelect(\DB::raw('phone_number as phone'))
|
||||
->selectRaw('sname as name')
|
||||
->selectRaw('phone_number as phone')
|
||||
->orderBy('sname')
|
||||
->get(),
|
||||
'sms_templates' => \App\Models\SmsTemplate::query()
|
||||
->select(['id', 'name', 'content', 'allow_custom_body'])
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
'email_templates' => \App\Models\EmailTemplate::query()
|
||||
->select(['id', 'name', 'subject_template', 'text_template', 'action_id', 'decision_id'])
|
||||
->where('active', true)
|
||||
->where('client', false)
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
'mail_profiles' => \App\Models\MailProfile::query()
|
||||
->select(['id', 'name'])
|
||||
->where('active', true)
|
||||
->orderBy('priority')
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1079,6 +1124,158 @@ public function archiveContract(ClientCase $clientCase, string $uuid, Request $r
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive multiple contracts in a batch operation
|
||||
*/
|
||||
public function archiveBatch(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'contracts' => 'required|array',
|
||||
'contracts.*' => 'required|uuid|exists:contracts,uuid',
|
||||
'reactivate' => 'boolean',
|
||||
]);
|
||||
|
||||
$reactivate = $validated['reactivate'] ?? false;
|
||||
|
||||
// Get archive setting
|
||||
$setting = \App\Models\ArchiveSetting::query()
|
||||
->where('enabled', true)
|
||||
->whereIn('strategy', ['immediate', 'manual'])
|
||||
->where('reactivate', $reactivate)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $setting) {
|
||||
\Log::warning('No archive settings found for batch archive');
|
||||
|
||||
return back()->with('flash', [
|
||||
'error' => 'No archive settings found',
|
||||
]);
|
||||
}
|
||||
|
||||
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
|
||||
$successCount = 0;
|
||||
$skippedCount = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($validated['contracts'] as $contractUuid) {
|
||||
try {
|
||||
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
|
||||
|
||||
// Skip if contract is already archived (active = 0)
|
||||
if (! $contract->active) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$clientCase = $contract->clientCase;
|
||||
|
||||
$context = [
|
||||
'contract_id' => $contract->id,
|
||||
'client_case_id' => $clientCase->id,
|
||||
'account_id' => $contract->account->id ?? null,
|
||||
];
|
||||
|
||||
// Execute archive setting
|
||||
$executor->executeSetting($setting, $context, \Auth::id());
|
||||
|
||||
// Transaction for segment updates and activity logging
|
||||
\DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) {
|
||||
// Create activity log
|
||||
if ($setting->action_id && $setting->decision_id) {
|
||||
$activityData = [
|
||||
'client_case_id' => $clientCase->id,
|
||||
'action_id' => $setting->action_id,
|
||||
'decision_id' => $setting->decision_id,
|
||||
'note' => ($reactivate)
|
||||
? "Ponovno aktivirana pogodba $contract->reference"
|
||||
: "Arhivirana pogodba $contract->reference",
|
||||
];
|
||||
|
||||
try {
|
||||
\App\Models\Activity::create($activityData);
|
||||
} catch (Exception $e) {
|
||||
\Log::warning('Activity could not be created during batch archive');
|
||||
}
|
||||
}
|
||||
|
||||
// Move to archive segment if specified
|
||||
if ($setting->segment_id) {
|
||||
$segmentId = $setting->segment_id;
|
||||
|
||||
// Deactivate all current segments
|
||||
$contract->segments()
|
||||
->allRelatedIds()
|
||||
->map(fn (int $val) => $contract->segments()->updateExistingPivot($val, [
|
||||
'active' => false,
|
||||
'updated_at' => now(),
|
||||
]));
|
||||
|
||||
// Activate archive segment
|
||||
if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) {
|
||||
$contract->attachedSegments()->updateExistingPivot($segmentId, [
|
||||
'active' => true,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
$contract->segments()->attach($segmentId, [
|
||||
'active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel pending field jobs
|
||||
$contract->fieldJobs()
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->update([
|
||||
'cancelled_at' => date('Y-m-d'),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
$successCount++;
|
||||
} catch (Exception $e) {
|
||||
\Log::error('Error archiving contract in batch', [
|
||||
'uuid' => $contractUuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$errors[] = [
|
||||
'uuid' => $contractUuid,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (count($errors) > 0) {
|
||||
$message = "Archived $successCount contracts";
|
||||
if ($skippedCount > 0) {
|
||||
$message .= ", skipped $skippedCount already archived";
|
||||
}
|
||||
$message .= ', '.count($errors).' failed';
|
||||
|
||||
return back()->with('flash', [
|
||||
'error' => $message,
|
||||
'details' => $errors,
|
||||
]);
|
||||
}
|
||||
|
||||
$message = $reactivate
|
||||
? "Successfully reactivated $successCount contracts"
|
||||
: "Successfully archived $successCount contracts";
|
||||
|
||||
if ($skippedCount > 0) {
|
||||
$message .= " ($skippedCount already archived)";
|
||||
}
|
||||
|
||||
return back()->with('flash', [
|
||||
'success' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency: recreate a missing / soft-deleted person for a client case and re-link related data.
|
||||
*/
|
||||
@@ -1195,10 +1392,10 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
||||
if (! empty($validated['sender_id'])) {
|
||||
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
|
||||
if (! $sender) {
|
||||
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
|
||||
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
|
||||
}
|
||||
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
|
||||
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
||||
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
||||
}
|
||||
}
|
||||
if (! $profile) {
|
||||
@@ -1241,7 +1438,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
||||
}
|
||||
|
||||
// Create an activity before sending
|
||||
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
|
||||
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
|
||||
$activityData = [
|
||||
'note' => $activityNote,
|
||||
'user_id' => optional($request->user())->id,
|
||||
@@ -1390,6 +1587,161 @@ public function previewSms(ClientCase $clientCase, Request $request, SmsService
|
||||
* Extracts 'value' from objects with {title, value, type} structure.
|
||||
* Also creates direct access aliases for nested fields (skipping numeric keys).
|
||||
*/
|
||||
/**
|
||||
* Render an email template preview with context from the client case.
|
||||
*/
|
||||
public function previewEmailForEmail(ClientCase $clientCase, Request $request, int $email_id): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'template_id' => ['required', 'integer', 'exists:email_templates,id'],
|
||||
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
|
||||
'body_text' => ['sometimes', 'nullable', 'string', 'max:10000'],
|
||||
]);
|
||||
|
||||
$email = \App\Models\Email::query()
|
||||
->where('id', $email_id)
|
||||
->where('person_id', $clientCase->person_id)
|
||||
->firstOrFail();
|
||||
|
||||
$template = \App\Models\EmailTemplate::findOrFail((int) $validated['template_id']);
|
||||
|
||||
$contract = null;
|
||||
if (! empty($validated['contract_uuid'])) {
|
||||
$contract = $clientCase->contracts()
|
||||
->where('uuid', $validated['contract_uuid'])
|
||||
->first();
|
||||
}
|
||||
|
||||
$ctx = $this->buildCaseEmailContext($clientCase, $contract);
|
||||
$ctx['body_text'] = (string) ($validated['body_text'] ?? '');
|
||||
|
||||
$renderer = app(\App\Services\EmailTemplateRenderer::class);
|
||||
$rendered = $renderer->render([
|
||||
'subject' => (string) $template->subject_template,
|
||||
'html' => (string) $template->html_template,
|
||||
'text' => (string) $template->text_template,
|
||||
], $ctx);
|
||||
|
||||
return response()->json([
|
||||
'subject' => $rendered['subject'] ?? '',
|
||||
'html' => (string) ($rendered['html'] ?? ''),
|
||||
'has_body_text' => (bool) preg_match('/{{\s*body_text\s*}}/', (string) $template->html_template),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a (possibly templated) email to a person email address belonging to this case.
|
||||
*/
|
||||
public function sendEmailToEmail(ClientCase $clientCase, Request $request, int $email_id)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'subject' => ['required', 'string', 'max:255'],
|
||||
'html_body' => ['nullable', 'string'],
|
||||
'body_text' => ['nullable', 'string', 'max:10000'],
|
||||
'template_id' => ['sometimes', 'nullable', 'integer', 'exists:email_templates,id'],
|
||||
'mail_profile_id' => ['sometimes', 'nullable', 'integer', 'exists:mail_profiles,id'],
|
||||
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
|
||||
]);
|
||||
|
||||
// Ensure the email belongs to the person of this case
|
||||
$email = \App\Models\Email::query()
|
||||
->where('id', $email_id)
|
||||
->where('person_id', $clientCase->person_id)
|
||||
->firstOrFail();
|
||||
|
||||
$to = (string) $email->value;
|
||||
|
||||
/** @var \App\Models\MailProfile|null $mailProfile */
|
||||
$mailProfile = ! empty($validated['mail_profile_id'])
|
||||
? \App\Models\MailProfile::query()->where('id', $validated['mail_profile_id'])->where('active', true)->first()
|
||||
: \App\Models\MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first();
|
||||
|
||||
if (! $mailProfile) {
|
||||
return back()->with('error', 'Ni aktivnega e-poštnega profila.');
|
||||
}
|
||||
|
||||
$contract = null;
|
||||
if (! empty($validated['contract_uuid'])) {
|
||||
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
|
||||
}
|
||||
|
||||
$htmlBody = (string) ($validated['html_body'] ?? '');
|
||||
$bodyText = (string) ($validated['body_text'] ?? '');
|
||||
|
||||
// Apply {{body_text}} substitution if the html body contains the placeholder
|
||||
if ($bodyText !== '' && preg_match('/{{\s*body_text\s*}}/', $htmlBody)) {
|
||||
$renderer = app(\App\Services\EmailTemplateRenderer::class);
|
||||
$htmlBody = $renderer->applyBodyText($htmlBody, $bodyText, html: true) ?? $htmlBody;
|
||||
}
|
||||
|
||||
$subject = (string) $validated['subject'];
|
||||
|
||||
$log = new \App\Models\EmailLog;
|
||||
$log->fill([
|
||||
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||
'template_id' => $validated['template_id'] ?? null,
|
||||
'mail_profile_id' => $mailProfile->id,
|
||||
'to_email' => $to,
|
||||
'to_recipients' => [$to],
|
||||
'subject' => $subject,
|
||||
'body_html_hash' => $htmlBody !== '' ? hash('sha256', $htmlBody) : null,
|
||||
'body_text_preview' => null,
|
||||
'embed_mode' => 'base64',
|
||||
'status' => \App\Models\EmailLogStatus::Queued,
|
||||
'queued_at' => now(),
|
||||
'client_id' => $clientCase->client_id,
|
||||
'client_case_id' => $clientCase->id,
|
||||
'contract_id' => $contract?->id,
|
||||
'ip' => $request->ip(),
|
||||
]);
|
||||
$log->save();
|
||||
|
||||
$log->body()->create([
|
||||
'body_html' => $htmlBody,
|
||||
'body_text' => $bodyText,
|
||||
'inline_css' => false,
|
||||
]);
|
||||
|
||||
dispatch(new \App\Jobs\SendEmailTemplateJob($log->id));
|
||||
|
||||
// Create activity if template has action/decision
|
||||
if (! empty($validated['template_id'])) {
|
||||
$template = \App\Models\EmailTemplate::find((int) $validated['template_id']);
|
||||
if ($template && ($template->action_id || $template->decision_id)) {
|
||||
$activity = $clientCase->activities()->create(array_filter([
|
||||
'contract_id' => $contract?->id,
|
||||
'action_id' => $template->action_id,
|
||||
'decision_id' => $template->decision_id,
|
||||
'note' => 'Poslano: '.$to.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
|
||||
'user_id' => optional($request->user())->id,
|
||||
], fn ($v) => ! is_null($v)));
|
||||
$activity->emailLogs()->attach($log->id);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', "E-pošta poslana na {$to}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a template rendering context from the given client case and optional contract.
|
||||
*/
|
||||
private function buildCaseEmailContext(ClientCase $clientCase, ?\App\Models\Contract $contract = null): array
|
||||
{
|
||||
$clientCase->loadMissing('client.person');
|
||||
$ctx = [
|
||||
'client_case' => $clientCase,
|
||||
'client' => $clientCase->client,
|
||||
'person' => optional($clientCase->client)->person,
|
||||
'mail_profile' => \App\Models\MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first(),
|
||||
];
|
||||
if ($contract) {
|
||||
$contract->loadMissing(['clientCase.client.person', 'account.type']);
|
||||
$ctx['contract'] = $contract;
|
||||
}
|
||||
|
||||
return $ctx;
|
||||
}
|
||||
|
||||
private function flattenMeta(array $meta, string $prefix = ''): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exports\ClientContractsExport;
|
||||
use App\Http\Requests\ExportClientContractsRequest;
|
||||
use App\Models\Client;
|
||||
use App\Services\ReferenceDataCache;
|
||||
use DB;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
@@ -23,7 +27,7 @@ public function index(Client $client, Request $request)
|
||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||
->groupBy('clients.id');
|
||||
})
|
||||
->where('clients.active', 1)
|
||||
// ->where('clients.active', 1)
|
||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
||||
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
||||
->leftJoin('contracts', function ($join) {
|
||||
@@ -36,18 +40,14 @@ public function index(Client $client, Request $request)
|
||||
})
|
||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->groupBy('clients.id')
|
||||
->addSelect([
|
||||
// Number of client cases for this client that have at least one active contract
|
||||
DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count'),
|
||||
// Sum of account balances for active contracts
|
||||
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
])
|
||||
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count')
|
||||
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
|
||||
->with('person')
|
||||
->orderByDesc('clients.created_at');
|
||||
|
||||
return Inertia::render('Client/Index', [
|
||||
'clients' => $query
|
||||
->paginate($request->integer('per_page', 15))
|
||||
->paginate($request->integer('per_page', default: 100))
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
@@ -67,6 +67,7 @@ public function show(Client $client, Request $request)
|
||||
|
||||
return Inertia::render('Client/Show', [
|
||||
'client' => $data,
|
||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||
'client_cases' => $data->clientCases()
|
||||
->select('client_cases.*')
|
||||
->when($request->input('search'), function ($que, $search) {
|
||||
@@ -84,10 +85,8 @@ public function show(Client $client, Request $request)
|
||||
})
|
||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->groupBy('client_cases.id')
|
||||
->addSelect([
|
||||
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
|
||||
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
])
|
||||
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
|
||||
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
|
||||
->with(['person', 'client.person'])
|
||||
->where('client_cases.active', 1)
|
||||
->orderByDesc('client_cases.created_at')
|
||||
@@ -137,6 +136,7 @@ public function contracts(Client $client, Request $request)
|
||||
->with([
|
||||
'clientCase:id,uuid,person_id',
|
||||
'clientCase.person:id,full_name',
|
||||
'clientCase.person.address',
|
||||
'segments' => function ($q) {
|
||||
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
||||
},
|
||||
@@ -157,6 +157,7 @@ public function contracts(Client $client, Request $request)
|
||||
|
||||
return Inertia::render('Client/Contracts', [
|
||||
'client' => $data,
|
||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||
'contracts' => $contractsQuery
|
||||
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
|
||||
->withQueryString(),
|
||||
@@ -166,6 +167,84 @@ public function contracts(Client $client, Request $request)
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportContracts(ExportClientContractsRequest $request, Client $client)
|
||||
{
|
||||
$data = $request->validated();
|
||||
$columns = array_values(array_unique($data['columns']));
|
||||
|
||||
$from = $data['from'] ?? null;
|
||||
$to = $data['to'] ?? null;
|
||||
$search = $data['search'] ?? null;
|
||||
$segmentsParam = $data['segments'] ?? null;
|
||||
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
|
||||
|
||||
$query = \App\Models\Contract::query()
|
||||
->whereHas('clientCase', function ($q) use ($client) {
|
||||
$q->where('client_id', $client->id);
|
||||
})
|
||||
->with([
|
||||
'clientCase:id,uuid,person_id',
|
||||
'clientCase.person:id,full_name',
|
||||
'clientCase.person.address',
|
||||
'segments' => function ($q) {
|
||||
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
||||
},
|
||||
'account:id,accounts.contract_id,balance_amount',
|
||||
])
|
||||
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
|
||||
->whereNull('deleted_at')
|
||||
->when($from || $to, function ($q) use ($from, $to) {
|
||||
if (! empty($from)) {
|
||||
$q->whereDate('start_date', '>=', $from);
|
||||
}
|
||||
if (! empty($to)) {
|
||||
$q->whereDate('start_date', '<=', $to);
|
||||
}
|
||||
})
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->where(function ($inner) use ($search) {
|
||||
$inner->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
||||
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
});
|
||||
})
|
||||
->when($segmentIds, function ($q) use ($segmentIds) {
|
||||
$q->whereHas('segments', function ($s) use ($segmentIds) {
|
||||
$s->whereIn('segments.id', $segmentIds)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
})
|
||||
->orderByDesc('start_date');
|
||||
|
||||
if (($data['scope'] ?? ExportClientContractsRequest::SCOPE_ALL) === ExportClientContractsRequest::SCOPE_CURRENT) {
|
||||
$page = max(1, (int) ($data['page'] ?? 1));
|
||||
$perPage = max(1, min(200, (int) ($data['per_page'] ?? 15)));
|
||||
$query->forPage($page, $perPage);
|
||||
}
|
||||
|
||||
$filename = $this->buildExportFilename($client);
|
||||
|
||||
return Excel::download(new ClientContractsExport($query, $columns), $filename);
|
||||
}
|
||||
|
||||
private function buildExportFilename(Client $client): string
|
||||
{
|
||||
$datePrefix = now()->format('dmy');
|
||||
$clientName = $this->slugify($client->person?->full_name ?? 'stranka');
|
||||
|
||||
return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName);
|
||||
}
|
||||
|
||||
private function slugify(?string $value): string
|
||||
{
|
||||
if (empty($value)) {
|
||||
return 'data';
|
||||
}
|
||||
|
||||
return Str::slug($value, '-') ?: 'data';
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
class ContractSettingController extends Controller
|
||||
{
|
||||
public function edit(): \Inertia\Response
|
||||
{
|
||||
$setting = \App\Models\ContractSetting::query()->first();
|
||||
if (! $setting) {
|
||||
$setting = \App\Models\ContractSetting::query()->create([
|
||||
'create_activity_on_balance_change' => false,
|
||||
'default_action_id' => null,
|
||||
'default_decision_id' => null,
|
||||
'activity_note_template' => 'Sprememba stanja pogodbe: {old_balance} → {new_balance} {currency}',
|
||||
]);
|
||||
}
|
||||
|
||||
$decisions = \App\Models\Decision::query()->orderBy('name')->get(['id', 'name']);
|
||||
$actions = \App\Models\Action::query()
|
||||
->with(['decisions:id'])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (\App\Models\Action $a) {
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'decision_ids' => $a->decisions->pluck('id')->values(),
|
||||
];
|
||||
});
|
||||
|
||||
return \Inertia\Inertia::render('Settings/Contracts/Index', [
|
||||
'setting' => [
|
||||
'id' => $setting->id,
|
||||
'create_activity_on_balance_change' => (bool) $setting->create_activity_on_balance_change,
|
||||
'default_action_id' => $setting->default_action_id,
|
||||
'default_decision_id' => $setting->default_decision_id,
|
||||
'activity_note_template' => $setting->activity_note_template,
|
||||
],
|
||||
'decisions' => $decisions,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(\App\Http\Requests\UpdateContractSettingRequest $request): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$setting = \App\Models\ContractSetting::query()->firstOrFail();
|
||||
|
||||
$data['create_activity_on_balance_change'] = (bool) ($data['create_activity_on_balance_change'] ?? false);
|
||||
|
||||
$setting->update($data);
|
||||
|
||||
return back()->with('success', 'Nastavitve shranjene.');
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -47,9 +46,9 @@ public function __invoke(SmsService $sms): Response
|
||||
return Account::whereHas('contract', function ($q) {
|
||||
$q->whereNull('deleted_at');
|
||||
})
|
||||
->whereNotNull('promise_date')
|
||||
->whereDate('promise_date', '>=', $today)
|
||||
->count();
|
||||
->whereNotNull('promise_date')
|
||||
->whereDate('promise_date', '>=', $today)
|
||||
->count();
|
||||
});
|
||||
|
||||
// Activities (limit 10) - cached
|
||||
@@ -80,14 +79,14 @@ public function __invoke(SmsService $sms): Response
|
||||
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
||||
|
||||
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
|
||||
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
|
||||
->selectRaw("DATE(COALESCE(assigned_at, created_at) AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
// Completed field jobs last 7 days
|
||||
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
||||
->whereBetween('completed_at', [$start, $end])
|
||||
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
|
||||
->selectRaw("DATE(completed_at AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
@@ -101,13 +100,13 @@ public function __invoke(SmsService $sms): Response
|
||||
// Field jobs assigned today - cached
|
||||
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
||||
return FieldJob::query()
|
||||
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
|
||||
->whereRaw('DATE(COALESCE(assigned_at, created_at)) = ?', [$today->toDateString()])
|
||||
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
||||
->with(['contract' => function ($q) {
|
||||
$q->select('id', 'uuid', 'reference', 'client_case_id')
|
||||
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
|
||||
}])
|
||||
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
|
||||
->orderByRaw('COALESCE(assigned_at, created_at) DESC')
|
||||
->limit(15)
|
||||
->get()
|
||||
->map(function ($fj) {
|
||||
@@ -120,20 +119,26 @@ public function __invoke(SmsService $sms): Response
|
||||
}
|
||||
}
|
||||
|
||||
if (! $contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $fj->id,
|
||||
'priority' => $fj->priority,
|
||||
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
||||
'created_at' => $fj->created_at?->toIso8601String(),
|
||||
'contract' => $contract ? [
|
||||
'contract' => [
|
||||
'uuid' => $contract->uuid,
|
||||
'reference' => $contract->reference,
|
||||
'client_case_uuid' => optional($contract->clientCase)->uuid,
|
||||
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
|
||||
'segment_id' => $segmentId,
|
||||
] : null,
|
||||
],
|
||||
];
|
||||
});
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
});
|
||||
|
||||
// System health for timestamp
|
||||
|
||||
@@ -62,7 +62,8 @@ public function index(Request $request)
|
||||
$unassignedClients = $unassignedContracts->get()
|
||||
->pluck('clientCase.client')
|
||||
->filter()
|
||||
->unique('id');
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
|
||||
$assignedContracts = Contract::query()
|
||||
@@ -98,7 +99,8 @@ public function index(Request $request)
|
||||
$assignedClients = $assignedContracts->get()
|
||||
->pluck('clientCase.client')
|
||||
->filter()
|
||||
->unique('id');
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
$users = User::query()->orderBy('name')->get(['id', 'name']);
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
use App\Models\ImportEvent;
|
||||
use App\Models\ImportTemplate;
|
||||
use App\Services\CsvImportService;
|
||||
use App\Services\Import\ImportServiceV2;
|
||||
use App\Services\Import\ImportSimulationServiceV2;
|
||||
use App\Services\ImportProcessor;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -65,6 +64,7 @@ public function index(Request $request)
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'from' => $paginator->firstItem(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'links' => $paginator->linkCollection()->toArray(),
|
||||
'path' => $paginator->path(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'to' => $paginator->lastItem(),
|
||||
@@ -184,12 +184,13 @@ public function store(Request $request)
|
||||
}
|
||||
|
||||
// Kick off processing of an import - simple synchronous step for now
|
||||
public function process(Import $import, Request $request, ImportServiceV2 $processor)
|
||||
public function process(Import $import, Request $request, ImportProcessor $processor)
|
||||
{
|
||||
$import->update(['status' => 'validating', 'started_at' => now()]);
|
||||
|
||||
|
||||
try {
|
||||
$result = $processor->process($import, user: $request->user());
|
||||
|
||||
return response()->json($result);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Import processing failed', [
|
||||
@@ -197,12 +198,12 @@ public function process(Import $import, Request $request, ImportServiceV2 $proce
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
|
||||
$import->update(['status' => 'failed']);
|
||||
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Import processing failed: ' . $e->getMessage(),
|
||||
'message' => 'Import processing failed: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
@@ -712,8 +713,6 @@ public function simulatePayments(Import $import, Request $request)
|
||||
* templates. For payments templates, payment-specific summaries/entities will be included
|
||||
* automatically by the simulation service when mappings contain the payment root.
|
||||
*
|
||||
* @param Import $import
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function simulate(Import $import, Request $request)
|
||||
@@ -829,4 +828,19 @@ public function destroy(Request $request, Import $import)
|
||||
|
||||
return back()->with('success', 'Import deleted successfully');
|
||||
}
|
||||
|
||||
// Download the original import file
|
||||
public function download(Import $import)
|
||||
{
|
||||
// Verify file exists
|
||||
if (! $import->disk || ! $import->path || ! Storage::disk($import->disk)->exists($import->path)) {
|
||||
return response()->json([
|
||||
'error' => 'File not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$fileName = $import->original_name ?? 'import_'.$import->uuid;
|
||||
|
||||
return Storage::disk($import->disk)->download($import->path, $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateInstallmentSettingRequest;
|
||||
use App\Models\Action;
|
||||
use App\Models\Decision;
|
||||
use App\Models\InstallmentSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class InstallmentSettingController extends Controller
|
||||
{
|
||||
public function edit(): Response
|
||||
{
|
||||
$setting = InstallmentSetting::query()->first();
|
||||
if (! $setting) {
|
||||
$setting = InstallmentSetting::query()->create([
|
||||
'default_currency' => 'EUR',
|
||||
'create_activity_on_installment' => false,
|
||||
'default_decision_id' => null,
|
||||
'default_action_id' => null,
|
||||
'activity_note_template' => 'Dodan obrok: {amount} {currency}',
|
||||
]);
|
||||
}
|
||||
|
||||
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
|
||||
$actions = Action::query()
|
||||
->with(['decisions:id'])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (Action $a) {
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'decision_ids' => $a->decisions->pluck('id')->values(),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Settings/Installments/Index', [
|
||||
'setting' => [
|
||||
'id' => $setting->id,
|
||||
'default_currency' => $setting->default_currency,
|
||||
'create_activity_on_installment' => (bool) $setting->create_activity_on_installment,
|
||||
'default_decision_id' => $setting->default_decision_id,
|
||||
'default_action_id' => $setting->default_action_id,
|
||||
'activity_note_template' => $setting->activity_note_template,
|
||||
],
|
||||
'decisions' => $decisions,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateInstallmentSettingRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$setting = InstallmentSetting::query()->firstOrFail();
|
||||
|
||||
$data['create_activity_on_installment'] = (bool) ($data['create_activity_on_installment'] ?? false);
|
||||
|
||||
$setting->update($data);
|
||||
|
||||
return back()->with('success', 'Nastavitve shranjene.');
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public function unread(Request $request)
|
||||
}
|
||||
|
||||
$today = now()->toDateString();
|
||||
$perPage = max(1, min(100, (int) $request->integer('perPage', 15)));
|
||||
$perPage = max(1, min(100, (int) $request->integer('per_page', 15)));
|
||||
$search = trim((string) $request->input('search', ''));
|
||||
$clientUuid = trim((string) $request->input('client', ''));
|
||||
$clientId = null;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BankAccount;
|
||||
use App\Models\Person\Person;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -22,14 +21,14 @@ public function update(Person $person, Request $request)
|
||||
'tax_number' => 'nullable|integer',
|
||||
'social_security_number' => 'nullable|integer',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'employer' => 'nullable|string|max:255',
|
||||
'birthday' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$person->update($attributes);
|
||||
|
||||
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function createAddress(Person $person, Request $request)
|
||||
@@ -72,7 +71,7 @@ public function updateAddress(Person $person, int $address_id, Request $request)
|
||||
$address->update($attributes);
|
||||
|
||||
return back()->with('success', 'Address updated')->with('flash_method', 'PUT');
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function deleteAddress(Person $person, int $address_id, Request $request)
|
||||
@@ -80,7 +79,6 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
|
||||
$address = $person->addresses()->findOrFail($address_id);
|
||||
$address->delete(); // soft delete
|
||||
|
||||
|
||||
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
|
||||
}
|
||||
|
||||
@@ -138,12 +136,19 @@ public function createEmail(Person $person, Request $request)
|
||||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'valid' => 'boolean',
|
||||
'failed' => 'boolean',
|
||||
'receive_auto_mails' => 'sometimes|boolean',
|
||||
'verified_at' => 'nullable|date',
|
||||
'preferences' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
'decision_ids' => 'nullable|array',
|
||||
'decision_ids.*' => 'integer|exists:decisions,id',
|
||||
]);
|
||||
|
||||
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
||||
unset($attributes['decision_ids']);
|
||||
$attributes['preferences'] = array_merge($attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
||||
|
||||
// Dedup: avoid duplicate email per person by value
|
||||
$email = $person->emails()->firstOrCreate([
|
||||
'value' => $attributes['value'],
|
||||
@@ -160,14 +165,21 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
||||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'valid' => 'boolean',
|
||||
'failed' => 'boolean',
|
||||
'receive_auto_mails' => 'sometimes|boolean',
|
||||
'verified_at' => 'nullable|date',
|
||||
'preferences' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
'decision_ids' => 'nullable|array',
|
||||
'decision_ids.*' => 'integer|exists:decisions,id',
|
||||
]);
|
||||
|
||||
$email = $person->emails()->findOrFail($email_id);
|
||||
|
||||
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
||||
unset($attributes['decision_ids']);
|
||||
$attributes['preferences'] = array_merge($email->preferences ?? [], $attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
||||
|
||||
$email->update($attributes);
|
||||
|
||||
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
|
||||
@@ -204,10 +216,8 @@ public function createTrr(Person $person, Request $request)
|
||||
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
||||
$trr = $person->bankAccounts()->create($attributes);
|
||||
|
||||
|
||||
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function updateTrr(Person $person, int $trr_id, Request $request)
|
||||
@@ -238,8 +248,7 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
|
||||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||
$trr->delete();
|
||||
|
||||
|
||||
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,42 +10,40 @@
|
||||
class PhoneViewController extends Controller
|
||||
{
|
||||
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
||||
public function index(Request $request)
|
||||
|
||||
public function index(Request $request): \Inertia\Response
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$search = $request->input('search');
|
||||
$clientFilter = $request->input('client');
|
||||
$perPage = $request->integer('per_page', 15);
|
||||
$perPage = max(1, min(100, $perPage));
|
||||
|
||||
$query = FieldJob::query()
|
||||
$eagerLoad = [
|
||||
'contract' => function ($q) {
|
||||
$q->with([
|
||||
'type:id,name',
|
||||
'account',
|
||||
'clientCase.person.address.type',
|
||||
'clientCase.person.phones',
|
||||
'clientCase.client:id,uuid,person_id',
|
||||
'clientCase.client.person:id,full_name',
|
||||
]);
|
||||
},
|
||||
];
|
||||
|
||||
$baseQuery = FieldJob::query()
|
||||
->where('assigned_user_id', $userId)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->with([
|
||||
'contract' => function ($q) {
|
||||
$q->with([
|
||||
'type:id,name',
|
||||
'account',
|
||||
'clientCase.person.address.type',
|
||||
'clientCase.person.phones',
|
||||
'clientCase.client:id,uuid,person_id',
|
||||
'clientCase.client.person:id,full_name',
|
||||
]);
|
||||
},
|
||||
])
|
||||
->orderByDesc('assigned_at');
|
||||
->with($eagerLoad);
|
||||
|
||||
// Apply client filter
|
||||
if ($clientFilter) {
|
||||
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
|
||||
$baseQuery->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
|
||||
$q->where('uuid', $clientFilter);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$baseQuery->where(function ($q) use ($search) {
|
||||
$q->whereHas('contract', function ($cq) use ($search) {
|
||||
$cq->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($pq) use ($search) {
|
||||
@@ -58,9 +56,14 @@ public function index(Request $request)
|
||||
});
|
||||
}
|
||||
|
||||
$jobs = $query->paginate($perPage)->withQueryString();
|
||||
$pendingQuery = (clone $baseQuery)
|
||||
->where(fn ($q) => $q->where('added_activity', false)->orWhereNull('added_activity'))
|
||||
->orderByDesc('assigned_at');
|
||||
|
||||
$processedQuery = (clone $baseQuery)
|
||||
->where('added_activity', true)
|
||||
->orderByDesc('assigned_at');
|
||||
|
||||
// Get unique clients for filter dropdown
|
||||
$clients = \App\Models\Client::query()
|
||||
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
|
||||
$q->where('assigned_user_id', $userId)
|
||||
@@ -77,7 +80,8 @@ public function index(Request $request)
|
||||
->values();
|
||||
|
||||
return Inertia::render('Phone/Index', [
|
||||
'jobs' => $jobs,
|
||||
'pendingJobs' => Inertia::scroll(fn () => $pendingQuery->paginate(15, pageName: 'pending')),
|
||||
'processedJobs' => Inertia::scroll(fn () => $processedQuery->paginate(15, pageName: 'processed')),
|
||||
'clients' => $clients,
|
||||
'view_mode' => 'assigned',
|
||||
'filters' => [
|
||||
@@ -87,13 +91,11 @@ public function index(Request $request)
|
||||
]);
|
||||
}
|
||||
|
||||
public function completedToday(Request $request)
|
||||
public function completedToday(Request $request): \Inertia\Response
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$search = $request->input('search');
|
||||
$clientFilter = $request->input('client');
|
||||
$perPage = $request->integer('per_page', 15);
|
||||
$perPage = max(1, min(100, $perPage));
|
||||
|
||||
$start = now()->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
@@ -138,9 +140,6 @@ public function completedToday(Request $request)
|
||||
});
|
||||
}
|
||||
|
||||
$jobs = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// Get unique clients for filter dropdown
|
||||
$clients = \App\Models\Client::query()
|
||||
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
|
||||
$q->where('assigned_user_id', $userId)
|
||||
@@ -157,7 +156,7 @@ public function completedToday(Request $request)
|
||||
->values();
|
||||
|
||||
return Inertia::render('Phone/Index', [
|
||||
'jobs' => $jobs,
|
||||
'completedJobs' => Inertia::scroll(fn () => $query->paginate(15, pageName: 'completed')),
|
||||
'clients' => $clients,
|
||||
'view_mode' => 'completed-today',
|
||||
'filters' => [
|
||||
|
||||
@@ -43,7 +43,7 @@ public function show(string $slug, Request $request)
|
||||
$inputs = $this->buildInputsArray($report);
|
||||
$filters = $this->validateFilters($inputs, $request);
|
||||
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
|
||||
|
||||
|
||||
$perPage = (int) ($request->integer('per_page') ?: 25);
|
||||
$query = $this->queryBuilder->build($report, $filters);
|
||||
$paginator = $query->paginate($perPage);
|
||||
@@ -279,16 +279,51 @@ public function clients(Request $request)
|
||||
$clients = \App\Models\Client::query()
|
||||
->with('person:id,full_name')
|
||||
->get()
|
||||
->map(fn($c) => [
|
||||
->map(fn ($c) => [
|
||||
'id' => $c->uuid,
|
||||
'name' => $c->person->full_name ?? 'Unknown'
|
||||
'name' => $c->person->full_name ?? 'Unknown',
|
||||
])
|
||||
->sortBy('name')
|
||||
->values();
|
||||
|
||||
|
||||
return response()->json($clients);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight actions lookup for select:action filters.
|
||||
*/
|
||||
public function actions(Request $request)
|
||||
{
|
||||
$actions = \App\Models\Action::query()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name'])
|
||||
->map(fn ($a) => ['id' => $a->id, 'name' => $a->name])
|
||||
->values();
|
||||
|
||||
return response()->json($actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight decisions lookup for select:decision filters.
|
||||
* Optionally filtered by action_id (for dependent filter UI).
|
||||
*/
|
||||
public function decisions(Request $request)
|
||||
{
|
||||
$actionId = $request->integer('action_id', 0) ?: null;
|
||||
|
||||
$q = \App\Models\Decision::query()->orderBy('name');
|
||||
|
||||
if ($actionId !== null) {
|
||||
$q->whereHas('actions', fn ($qq) => $qq->where('actions.id', $actionId));
|
||||
}
|
||||
|
||||
$decisions = $q->get(['id', 'name'])
|
||||
->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])
|
||||
->values();
|
||||
|
||||
return response()->json($decisions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build validation rules based on inputs descriptor and validate.
|
||||
*
|
||||
@@ -307,6 +342,8 @@ protected function validateFilters(array $inputs, Request $request): array
|
||||
'integer' => [$nullable, 'integer'],
|
||||
'select:user' => [$nullable, 'integer', 'exists:users,id'],
|
||||
'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
|
||||
'select:action' => [$nullable, 'integer', 'exists:actions,id'],
|
||||
'select:decision' => [$nullable, 'integer', 'exists:decisions,id'],
|
||||
default => [$nullable, 'string'],
|
||||
};
|
||||
}
|
||||
@@ -319,7 +356,7 @@ protected function validateFilters(array $inputs, Request $request): array
|
||||
*/
|
||||
protected function buildInputsArray(Report $report): array
|
||||
{
|
||||
return $report->filters->map(fn($filter) => [
|
||||
return $report->filters->map(fn ($filter) => [
|
||||
'key' => $filter->key,
|
||||
'type' => $filter->type,
|
||||
'label' => $filter->label,
|
||||
@@ -336,7 +373,7 @@ protected function buildColumnsArray(Report $report): array
|
||||
{
|
||||
return $report->columns
|
||||
->where('visible', true)
|
||||
->map(fn($col) => [
|
||||
->map(fn ($col) => [
|
||||
'key' => $col->key,
|
||||
'label' => $col->label,
|
||||
])
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use App\Models\Decision;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\Segment;
|
||||
use App\Services\DecisionEvents\ConditionEvaluator;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
@@ -22,6 +23,8 @@ public function index(Request $request)
|
||||
'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']),
|
||||
'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']),
|
||||
'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']),
|
||||
'condition_fields' => ConditionEvaluator::availableFields(),
|
||||
'condition_operators' => ConditionEvaluator::availableOperators(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -83,6 +86,9 @@ public function updateAction(int $id, Request $request)
|
||||
|
||||
public function storeDecision(Request $request)
|
||||
{
|
||||
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
|
||||
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
|
||||
|
||||
$attributes = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'color_tag' => 'nullable|string|max:25',
|
||||
@@ -96,6 +102,14 @@ public function storeDecision(Request $request)
|
||||
'events.*.active' => 'sometimes|boolean',
|
||||
'events.*.run_order' => 'nullable|integer',
|
||||
'events.*.config' => 'nullable|array',
|
||||
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
|
||||
'events.*.config.deactivate_previous' => 'sometimes|boolean',
|
||||
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
|
||||
'events.*.config.reactivate' => 'sometimes|boolean',
|
||||
'events.*.config.conditions' => 'nullable|array',
|
||||
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
|
||||
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
|
||||
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
|
||||
]);
|
||||
|
||||
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
||||
@@ -112,12 +126,12 @@ public function storeDecision(Request $request)
|
||||
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||
if ($key === 'add_segment') {
|
||||
$seg = $ev['config']['segment_id'] ?? null;
|
||||
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
|
||||
if (empty($seg)) {
|
||||
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
||||
}
|
||||
} elseif ($key === 'archive_contract') {
|
||||
$as = $ev['config']['archive_setting_id'] ?? null;
|
||||
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) {
|
||||
if (empty($as)) {
|
||||
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
|
||||
}
|
||||
}
|
||||
@@ -174,6 +188,9 @@ public function updateDecision(int $id, Request $request)
|
||||
{
|
||||
$row = Decision::findOrFail($id);
|
||||
|
||||
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
|
||||
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
|
||||
|
||||
$attributes = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'color_tag' => 'nullable|string|max:25',
|
||||
@@ -187,6 +204,14 @@ public function updateDecision(int $id, Request $request)
|
||||
'events.*.active' => 'sometimes|boolean',
|
||||
'events.*.run_order' => 'nullable|integer',
|
||||
'events.*.config' => 'nullable|array',
|
||||
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
|
||||
'events.*.config.deactivate_previous' => 'sometimes|boolean',
|
||||
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
|
||||
'events.*.config.reactivate' => 'sometimes|boolean',
|
||||
'events.*.config.conditions' => 'nullable|array',
|
||||
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
|
||||
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
|
||||
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
|
||||
]);
|
||||
|
||||
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
||||
@@ -203,12 +228,12 @@ public function updateDecision(int $id, Request $request)
|
||||
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||
if ($key === 'add_segment') {
|
||||
$seg = $ev['config']['segment_id'] ?? null;
|
||||
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
|
||||
if (empty($seg)) {
|
||||
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
||||
}
|
||||
} elseif ($key === 'archive_contract') {
|
||||
$as = $ev['config']['archive_setting_id'] ?? null;
|
||||
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) {
|
||||
if (empty($as)) {
|
||||
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,15 @@ public function share(Request $request): array
|
||||
'info' => fn () => $request->session()->get('info'),
|
||||
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
|
||||
],
|
||||
'callLaterCount' => function () use ($request) {
|
||||
if (! $request->user()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return \App\Models\CallLater::query()
|
||||
->whereNull('completed_at')
|
||||
->count();
|
||||
},
|
||||
'notifications' => function () use ($request) {
|
||||
try {
|
||||
$user = $request->user();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Exports\ClientContractsExport;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ExportClientContractsRequest extends FormRequest
|
||||
{
|
||||
public const SCOPE_CURRENT = 'current';
|
||||
|
||||
public const SCOPE_ALL = 'all';
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$columnRule = Rule::in(ClientContractsExport::allowedColumns());
|
||||
|
||||
return [
|
||||
'scope' => ['required', Rule::in([self::SCOPE_CURRENT, self::SCOPE_ALL])],
|
||||
'columns' => ['required', 'array', 'min:1'],
|
||||
'columns.*' => ['string', $columnRule],
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'from' => ['nullable', 'date'],
|
||||
'to' => ['nullable', 'date'],
|
||||
'segments' => ['nullable', 'string'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'per_page' => $this->input('per_page') ?? $this->input('perPage'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreEmailPackageFromContractsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('manage-settings') ?? false;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', 'in:email'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
|
||||
// Common payload for all items
|
||||
'payload' => ['required', 'array'],
|
||||
'payload.mail_profile_id' => ['nullable', 'integer', 'exists:mail_profiles,id'],
|
||||
'payload.template_id' => ['nullable', 'integer', 'exists:email_templates,id'],
|
||||
'payload.subject' => ['nullable', 'string', 'max:255'],
|
||||
'payload.body_text' => ['nullable', 'string', 'max:10000'],
|
||||
'payload.variables' => ['nullable', 'array'],
|
||||
|
||||
// Source contracts to derive items from
|
||||
'contract_ids' => ['required', 'array', 'min:1'],
|
||||
'contract_ids.*' => ['integer', 'exists:contracts,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ public function rules(): array
|
||||
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
||||
'allow_attachments' => ['sometimes', 'boolean'],
|
||||
'active' => ['boolean'],
|
||||
'client' => ['sometimes', 'boolean'],
|
||||
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreInstallmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'numeric', 'min:0.01'],
|
||||
'currency' => ['nullable', 'string', 'size:3'],
|
||||
'reference' => ['nullable', 'string', 'max:100'],
|
||||
'installment_at' => ['nullable', 'date'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ public function rules(): array
|
||||
'reply_to_name' => ['nullable', 'string', 'max:190'],
|
||||
'priority' => ['nullable', 'integer', 'between:0,65535'],
|
||||
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
|
||||
'signature' => ['nullable', 'array'],
|
||||
'signature.*' => ['nullable', 'string', 'max:1000'],
|
||||
'auto_mailer' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateContractSettingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'create_activity_on_balance_change' => ['sometimes', 'boolean'],
|
||||
'default_action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||
'default_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||
'activity_note_template' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ public function rules(): array
|
||||
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
||||
'allow_attachments' => ['sometimes', 'boolean'],
|
||||
'active' => ['boolean'],
|
||||
'client' => ['sometimes', 'boolean'],
|
||||
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateInstallmentSettingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'default_currency' => ['required', 'string', 'size:3'],
|
||||
'create_activity_on_installment' => ['sometimes', 'boolean'],
|
||||
'default_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||
'default_action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||
'activity_note_template' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ public function rules(): array
|
||||
'priority' => ['nullable', 'integer', 'between:0,65535'],
|
||||
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
|
||||
'active' => ['nullable', 'boolean'],
|
||||
'signature' => ['nullable', 'array'],
|
||||
'signature.*' => ['nullable', 'string', 'max:1000'],
|
||||
'auto_mailer' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||
|
||||
class LoginResponse implements LoginResponseContract
|
||||
{
|
||||
public function toResponse($request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$default = $user?->login_redirect ?: config('fortify.home');
|
||||
|
||||
return redirect()->intended($default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Contract;
|
||||
use App\Models\Email;
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailLogStatus;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\MailProfile;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackageItem;
|
||||
use App\Services\EmailSender;
|
||||
use App\Services\EmailTemplateRenderer;
|
||||
use Illuminate\Bus\Batchable;
|
||||
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\Str;
|
||||
|
||||
class PackageItemEmailJob implements ShouldQueue
|
||||
{
|
||||
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public int $packageItemId)
|
||||
{
|
||||
$this->onQueue('email');
|
||||
}
|
||||
|
||||
public function handle(EmailTemplateRenderer $renderer, EmailSender $sender): void
|
||||
{
|
||||
/** @var PackageItem|null $item */
|
||||
$item = PackageItem::query()->find($this->packageItemId);
|
||||
if (! $item) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Package $package */
|
||||
$package = $item->package;
|
||||
if (! $package || $package->status === Package::STATUS_CANCELED) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($item->status, ['sent', 'failed', 'canceled', 'skipped'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($item->status === 'queued') {
|
||||
$item->status = 'processing';
|
||||
$item->save();
|
||||
$package->increment('processing_count');
|
||||
}
|
||||
|
||||
$payload = (array) $item->payload_json;
|
||||
$target = (array) $item->target_json;
|
||||
|
||||
$to = $target['email'] ?? null;
|
||||
if (! is_string($to) || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
$item->status = 'failed';
|
||||
$item->last_error = 'Missing or invalid recipient email.';
|
||||
$item->save();
|
||||
$this->updatePackageCounters($item, $package);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$templateId = $payload['template_id'] ?? null;
|
||||
$mailProfileId = $payload['mail_profile_id'] ?? null;
|
||||
$variables = (array) ($payload['variables'] ?? []);
|
||||
$subjectOverride = isset($payload['subject']) ? trim((string) $payload['subject']) : null;
|
||||
if ($subjectOverride === '') {
|
||||
$subjectOverride = null;
|
||||
}
|
||||
$bodyText = isset($payload['body_text']) ? (string) $payload['body_text'] : '';
|
||||
|
||||
// Enrich variables with contract/account context when available
|
||||
$contract = null;
|
||||
if (! empty($target['contract_id'])) {
|
||||
$contract = Contract::query()->with(['clientCase.person', 'account.type'])->find($target['contract_id']);
|
||||
if ($contract) {
|
||||
$variables['contract'] = [
|
||||
'id' => $contract->id,
|
||||
'uuid' => $contract->uuid,
|
||||
'reference' => $contract->reference,
|
||||
'start_date' => (string) ($contract->start_date ?? ''),
|
||||
'end_date' => (string) ($contract->end_date ?? ''),
|
||||
];
|
||||
if (is_array($contract->meta) && ! empty($contract->meta)) {
|
||||
$variables['contract']['meta'] = $this->flattenMeta($contract->meta);
|
||||
}
|
||||
if ($contract->account) {
|
||||
$initialRaw = (string) $contract->account->initial_amount;
|
||||
$balanceRaw = (string) $contract->account->balance_amount;
|
||||
$variables['account'] = [
|
||||
'id' => $contract->account->id,
|
||||
'reference' => $contract->account->reference,
|
||||
'initial_amount' => $this->formatAmountEu($initialRaw),
|
||||
'balance_amount' => $this->formatAmountEu($balanceRaw),
|
||||
'initial_amount_raw' => $initialRaw,
|
||||
'balance_amount_raw' => $balanceRaw,
|
||||
'type' => $contract->account->type?->name,
|
||||
];
|
||||
}
|
||||
if ($contract->clientCase?->person) {
|
||||
$person = $contract->clientCase->person;
|
||||
$variables['person'] = [
|
||||
'full_name' => $person->full_name,
|
||||
'first_name' => $person->first_name,
|
||||
'last_name' => $person->last_name,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @var EmailTemplate|null $template */
|
||||
$template = $templateId ? EmailTemplate::with(['action', 'decision'])->find((int) $templateId) : null;
|
||||
|
||||
/** @var MailProfile|null $mailProfile */
|
||||
$mailProfile = $mailProfileId
|
||||
? MailProfile::find((int) $mailProfileId)
|
||||
: MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first();
|
||||
|
||||
try {
|
||||
if (! $template && ! $subjectOverride) {
|
||||
throw new \RuntimeException('No email template or subject provided.');
|
||||
}
|
||||
|
||||
$rendered = $template
|
||||
? $renderer->render([
|
||||
'subject' => $subjectOverride ?? (string) $template->subject_template,
|
||||
'html' => (string) $template->html_template,
|
||||
'text' => (string) $template->text_template,
|
||||
], array_filter([
|
||||
'contract' => $contract,
|
||||
'person' => $contract?->clientCase?->person,
|
||||
'client' => $contract?->clientCase?->client,
|
||||
'client_case' => $contract?->clientCase,
|
||||
'mail_profile' => $mailProfile,
|
||||
'extra' => $variables,
|
||||
'body_text' => $bodyText !== '' ? $bodyText : null,
|
||||
]))
|
||||
: [
|
||||
'subject' => $subjectOverride ?? '',
|
||||
'html' => null,
|
||||
'text' => null,
|
||||
];
|
||||
|
||||
$log = new EmailLog;
|
||||
$log->fill([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'template_id' => $template?->id,
|
||||
'mail_profile_id' => $mailProfile?->id,
|
||||
'to_email' => $to,
|
||||
'to_recipients' => [$to],
|
||||
'subject' => $rendered['subject'],
|
||||
'body_html_hash' => isset($rendered['html']) ? hash('sha256', (string) $rendered['html']) : null,
|
||||
'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null,
|
||||
'embed_mode' => 'base64',
|
||||
'status' => EmailLogStatus::Queued,
|
||||
'queued_at' => now(),
|
||||
'contract_id' => $contract?->id,
|
||||
'client_id' => $contract?->clientCase?->client?->id,
|
||||
'client_case_id' => $contract?->clientCase?->id,
|
||||
'extra_context' => ['package_id' => $item->package_id, 'package_item_id' => $item->id],
|
||||
]);
|
||||
$log->save();
|
||||
|
||||
$log->body()->create([
|
||||
'body_html' => (string) ($rendered['html'] ?? ''),
|
||||
'body_text' => (string) ($rendered['text'] ?? ''),
|
||||
'inline_css' => true,
|
||||
]);
|
||||
|
||||
// Send directly (synchronous within job context)
|
||||
$start = microtime(true);
|
||||
$log->status = EmailLogStatus::Sending;
|
||||
$log->started_at = now();
|
||||
$log->attempt = 1;
|
||||
$log->save();
|
||||
|
||||
$sender->sendFromLog($log);
|
||||
|
||||
$log->status = EmailLogStatus::Sent;
|
||||
$log->sent_at = now();
|
||||
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
|
||||
$log->save();
|
||||
|
||||
$item->status = 'sent';
|
||||
$item->result_json = ['email_log_id' => $log->id, 'subject' => $rendered['subject']];
|
||||
$item->last_error = null;
|
||||
$item->save();
|
||||
|
||||
// Clear failed flag on successful delivery
|
||||
Email::query()->where('value', $to)->where('failed', true)->update(['failed' => false]);
|
||||
|
||||
// Create activity if the template has action/decision configured
|
||||
if ($template && ($template->action_id || $template->decision_id) && $contract && $contract->client_case_id) {
|
||||
$activity = \App\Models\Activity::create(array_filter([
|
||||
'client_case_id' => $contract->client_case_id,
|
||||
'contract_id' => $contract->id,
|
||||
'action_id' => $template->action_id,
|
||||
'decision_id' => $template->decision_id,
|
||||
'note' => 'Poslano: '.$to.', Uspešno'.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
|
||||
]));
|
||||
$activity->emailLogs()->attach($log->id);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$item->status = 'failed';
|
||||
$item->last_error = $e->getMessage();
|
||||
$item->save();
|
||||
|
||||
// Create activity for failed send if the template has action/decision configured
|
||||
if ($template && ($template->action_id || $template->decision_id) && isset($contract) && $contract && $contract->client_case_id) {
|
||||
$shortError = mb_strimwidth($e->getMessage(), 0, 120, '…');
|
||||
$activity = \App\Models\Activity::create(array_filter([
|
||||
'client_case_id' => $contract->client_case_id,
|
||||
'contract_id' => $contract->id,
|
||||
'action_id' => $template->action_id,
|
||||
'decision_id' => $template->decision_id,
|
||||
'note' => 'Poslano: '.$to.', Napaka pri pošiljanju: '.$shortError.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
|
||||
]));
|
||||
if (isset($log) && $log->exists) {
|
||||
$activity->emailLogs()->attach($log->id);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the email address as failed in the DB.
|
||||
if (isset($to)) {
|
||||
Email::query()
|
||||
->where('value', $to)
|
||||
->update(['failed' => true]);
|
||||
}
|
||||
|
||||
// Permanent SMTP rejection (550 user unknown, 551 not local, 553 invalid address)
|
||||
// means the address definitively does not exist — also mark it invalid.
|
||||
if ($e instanceof \Symfony\Component\Mailer\Exception\TransportExceptionInterface
|
||||
&& preg_match('/\b55[013]\b/', $e->getMessage())
|
||||
&& isset($to)) {
|
||||
Email::query()
|
||||
->where('value', $to)
|
||||
->update(['valid' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->updatePackageCounters($item, $package);
|
||||
}
|
||||
|
||||
private function updatePackageCounters(PackageItem $item, Package $package): void
|
||||
{
|
||||
if ($item->status === 'sent') {
|
||||
$package->increment('sent_count');
|
||||
} else {
|
||||
$package->increment('failed_count');
|
||||
}
|
||||
|
||||
$package->decrement('processing_count');
|
||||
|
||||
$package->refresh();
|
||||
$done = $package->sent_count + $package->failed_count;
|
||||
if ($done >= $package->total_items) {
|
||||
$package->status = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED;
|
||||
$package->finished_at = now();
|
||||
$package->save();
|
||||
}
|
||||
}
|
||||
|
||||
private function flattenMeta(array $meta, string $prefix = ''): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($meta as $key => $value) {
|
||||
$newKey = $prefix === '' ? $key : "{$prefix}.{$key}";
|
||||
if (is_array($value)) {
|
||||
if (isset($value['value'])) {
|
||||
$result[$newKey] = $value['value'];
|
||||
} else {
|
||||
$nested = $this->flattenMeta($value, $newKey);
|
||||
$result = array_merge($result, $nested);
|
||||
}
|
||||
} else {
|
||||
$result[$newKey] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function formatAmountEu(string $raw): string
|
||||
{
|
||||
$numeric = preg_replace('/[^0-9.]/', '', $raw);
|
||||
$float = (float) $numeric;
|
||||
|
||||
return number_format($float, 2, ',', '.');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\Event as DecisionEventModel;
|
||||
use App\Services\DecisionEvents\ConditionEvaluator;
|
||||
use App\Services\DecisionEvents\DecisionEventContext;
|
||||
use App\Services\DecisionEvents\Registry;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -68,6 +69,23 @@ public function handle(): void
|
||||
user: $activity->user,
|
||||
);
|
||||
|
||||
// [2] Condition check — skip the event if any condition is not met
|
||||
$conditions = $this->config['conditions'] ?? [];
|
||||
if (! empty($conditions)) {
|
||||
$conditionsMet = app(ConditionEvaluator::class)->evaluate($conditions, $context);
|
||||
if (! $conditionsMet) {
|
||||
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
||||
'status' => 'skipped',
|
||||
'message' => 'Condition not met',
|
||||
'finished_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// [3] Resolve handler → handle()
|
||||
$handler->handle($context, $this->config);
|
||||
|
||||
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Email;
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailLogStatus;
|
||||
use App\Services\EmailSender;
|
||||
@@ -53,6 +54,10 @@ public function handle(): void
|
||||
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
|
||||
$log->save();
|
||||
|
||||
if ($log->to_email) {
|
||||
Email::query()->where('value', $log->to_email)->update(['failed' => true]);
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,10 +118,10 @@ public function handle(SmsService $sms): void
|
||||
if ($template && $case) {
|
||||
$note = '';
|
||||
if ($log->status === 'sent') {
|
||||
$note = sprintf('Št: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
||||
$note = sprintf('Tel: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
||||
} elseif ($log->status === 'failed') {
|
||||
$note = sprintf(
|
||||
'Št: %s | Telo: %s | Napaka: %s',
|
||||
'Tel: %s | Telo: %s | Napaka: %s',
|
||||
(string) $this->to,
|
||||
(string) $this->content,
|
||||
'SMS ni bil poslan!'
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Account extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'reference',
|
||||
'description',
|
||||
@@ -56,6 +59,11 @@ public function payments(): HasMany
|
||||
return $this->hasMany(\App\Models\Payment::class);
|
||||
}
|
||||
|
||||
public function installments(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Installment::class);
|
||||
}
|
||||
|
||||
public function bookings(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Booking::class);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Activity extends Model
|
||||
@@ -18,6 +19,7 @@ class Activity extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'due_date',
|
||||
'call_back_at',
|
||||
'amount',
|
||||
'note',
|
||||
'action_id',
|
||||
@@ -27,6 +29,13 @@ class Activity extends Model
|
||||
'client_case_id',
|
||||
];
|
||||
|
||||
/*protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'call_back_at' => 'datetime',
|
||||
];
|
||||
}*/
|
||||
|
||||
protected $hidden = [
|
||||
'action_id',
|
||||
'decision_id',
|
||||
@@ -146,4 +155,14 @@ public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class);
|
||||
}
|
||||
|
||||
public function callLaters(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\CallLater::class);
|
||||
}
|
||||
|
||||
public function emailLogs(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(EmailLog::class, 'activity_email_logs');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CallLater extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'activity_id',
|
||||
'client_case_id',
|
||||
'contract_id',
|
||||
'user_id',
|
||||
'call_back_at',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'call_back_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function activity(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Activity::class);
|
||||
}
|
||||
|
||||
public function clientCase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientCase::class);
|
||||
}
|
||||
|
||||
public function contract(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Contract::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ContractSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'create_activity_on_balance_change',
|
||||
'default_action_id',
|
||||
'default_decision_id',
|
||||
'activity_note_template',
|
||||
];
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class Email extends Model
|
||||
'is_primary',
|
||||
'is_active',
|
||||
'valid',
|
||||
'failed',
|
||||
'receive_auto_mails',
|
||||
'verified_at',
|
||||
'preferences',
|
||||
@@ -28,6 +29,7 @@ class Email extends Model
|
||||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'valid' => 'boolean',
|
||||
'failed' => 'boolean',
|
||||
'receive_auto_mails' => 'boolean',
|
||||
'verified_at' => 'datetime',
|
||||
'preferences' => 'array',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
enum EmailLogStatus: string
|
||||
@@ -83,4 +84,9 @@ public function body(): HasOne
|
||||
{
|
||||
return $this->hasOne(EmailLogBody::class, 'email_log_id');
|
||||
}
|
||||
|
||||
public function activities(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Activity::class, 'activity_email_logs');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
class EmailTemplate extends Model
|
||||
@@ -19,10 +20,14 @@ class EmailTemplate extends Model
|
||||
'entity_types',
|
||||
'allow_attachments',
|
||||
'active',
|
||||
'action_id',
|
||||
'decision_id',
|
||||
'client',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => 'boolean',
|
||||
'client' => 'boolean',
|
||||
'entity_types' => 'array',
|
||||
'allow_attachments' => 'boolean',
|
||||
];
|
||||
@@ -31,4 +36,14 @@ public function documents(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Document::class, 'documentable');
|
||||
}
|
||||
|
||||
public function action(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Action::class);
|
||||
}
|
||||
|
||||
public function decision(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Decision::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Installment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'account_id',
|
||||
'amount',
|
||||
'balance_before',
|
||||
'currency',
|
||||
'reference',
|
||||
'installment_at',
|
||||
'meta',
|
||||
'created_by',
|
||||
'activity_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'installment_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
'amount' => 'decimal:4',
|
||||
'balance_before' => 'decimal:4',
|
||||
];
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
|
||||
public function activity(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Activity::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InstallmentSetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'default_currency',
|
||||
'create_activity_on_installment',
|
||||
'default_decision_id',
|
||||
'default_action_id',
|
||||
'activity_note_template',
|
||||
];
|
||||
}
|
||||
@@ -10,13 +10,15 @@ class MailProfile extends Model
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'active', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
|
||||
'reply_to_address', 'reply_to_name', 'priority', 'max_daily_quota', 'emails_sent_today',
|
||||
'name', 'active', 'auto_mailer', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
|
||||
'reply_to_address', 'reply_to_name', 'priority', 'signature', 'max_daily_quota', 'emails_sent_today',
|
||||
'last_success_at', 'last_error_at', 'last_error_message', 'failover_to_id', 'test_status', 'test_checked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => 'boolean',
|
||||
'auto_mailer' => 'boolean',
|
||||
'signature' => 'array',
|
||||
'last_success_at' => 'datetime',
|
||||
'last_error_at' => 'datetime',
|
||||
'test_checked_at' => 'datetime',
|
||||
|
||||
@@ -34,6 +34,8 @@ public function items()
|
||||
|
||||
public const TYPE_SMS = 'sms';
|
||||
|
||||
public const TYPE_EMAIL = 'email';
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
|
||||
@@ -46,6 +46,7 @@ class Person extends Model
|
||||
'group_id',
|
||||
'type_id',
|
||||
'user_id',
|
||||
'employer'
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
|
||||
@@ -31,6 +31,7 @@ class User extends Authenticatable
|
||||
'email',
|
||||
'password',
|
||||
'active',
|
||||
'login_redirect',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use App\Http\Responses\LoginResponse;
|
||||
use App\Models\User;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -14,6 +15,7 @@
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
@@ -23,7 +25,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->singleton(LoginResponseContract::class, LoginResponse::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -59,10 +59,23 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
||||
// Resolve eligible recipients: client's person emails with receive_auto_mails = true
|
||||
$recipients = [];
|
||||
if ($client && $client->person) {
|
||||
$recipients = Email::query()
|
||||
$emails = Email::query()
|
||||
->where('person_id', $client->person->id)
|
||||
->where('is_active', true)
|
||||
->where('receive_auto_mails', true)
|
||||
->get(['value', 'preferences']);
|
||||
|
||||
$recipients = $emails
|
||||
->filter(function (Email $email) use ($decision): bool {
|
||||
$decisionIds = $email->preferences['decision_ids'] ?? [];
|
||||
|
||||
// Empty list means "all decisions" — always receive
|
||||
if (empty($decisionIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array((int) $decision->id, array_map('intval', $decisionIds), true);
|
||||
})
|
||||
->pluck('value')
|
||||
->map(fn ($v) => strtolower(trim((string) $v)))
|
||||
->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL))
|
||||
@@ -77,7 +90,30 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
||||
// Ensure related names are available without extra queries
|
||||
$activity->loadMissing(['action', 'decision']);
|
||||
|
||||
// Ensure account is available on contract (needed for contract.account.* tokens)
|
||||
if ($contract && ! $contract->relationLoaded('account')) {
|
||||
$contract->load('account');
|
||||
}
|
||||
|
||||
// Resolve the sending profile once — used both for signature tokens and as the actual sender.
|
||||
// Prefer the profile explicitly requested via options, fall back to highest-priority active one.
|
||||
$mailProfile = isset($options['mail_profile_id'])
|
||||
? MailProfile::query()->find($options['mail_profile_id'])
|
||||
: null;
|
||||
$mailProfile ??= MailProfile::query()
|
||||
->where('active', true)
|
||||
->where('auto_mailer', true)
|
||||
->orderBy('priority')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
$mailProfile ??= MailProfile::query()
|
||||
->where('active', true)
|
||||
->orderBy('priority')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
// Render content
|
||||
$bodyText = isset($options['body_text']) ? (string) $options['body_text'] : '';
|
||||
$rendered = $this->renderer->render([
|
||||
'subject' => (string) $template->subject_template,
|
||||
'html' => (string) $template->html_template,
|
||||
@@ -89,6 +125,8 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
||||
'person' => $person,
|
||||
'activity' => $activity,
|
||||
'extra' => [],
|
||||
'mail_profile' => $mailProfile,
|
||||
'body_text' => $bodyText,
|
||||
]);
|
||||
|
||||
// Create the log and body
|
||||
@@ -96,7 +134,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
||||
$log->fill([
|
||||
'uuid' => (string) \Str::uuid(),
|
||||
'template_id' => $template->id,
|
||||
'mail_profile_id' => optional(MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first())->id,
|
||||
'mail_profile_id' => $mailProfile?->id,
|
||||
'user_id' => auth()->id(),
|
||||
'to_email' => (string) ($recipients[0] ?? ''),
|
||||
'to_recipients' => $recipients,
|
||||
@@ -136,7 +174,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
||||
|
||||
$log->body()->create([
|
||||
'body_html' => (string) ($rendered['html'] ?? ''),
|
||||
'body_text' => (string) ($rendered['text'] ?? ''),
|
||||
'body_text' => $bodyText !== '' ? $bodyText : (string) ($rendered['text'] ?? ''),
|
||||
'inline_css' => true,
|
||||
]);
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
class ClientCaseDataService
|
||||
{
|
||||
/**
|
||||
* Get paginated contracts for a client case with optional segment filtering.
|
||||
* Get contracts for a client case with optional segment filtering.
|
||||
*/
|
||||
public function getContracts(ClientCase $clientCase, ?int $segmentId = null, int $perPage = 50): LengthAwarePaginator
|
||||
public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Collection
|
||||
{
|
||||
$query = $clientCase->contracts()
|
||||
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
|
||||
@@ -40,9 +40,7 @@ public function getContracts(ClientCase $clientCase, ?int $segmentId = null, int
|
||||
$query->forSegment($segmentId);
|
||||
}
|
||||
|
||||
$perPage = max(1, min(100, $perPage));
|
||||
|
||||
return $query->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +54,7 @@ public function getActivities(
|
||||
int $perPage = 20
|
||||
): LengthAwarePaginator {
|
||||
$query = $clientCase->activities()
|
||||
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
|
||||
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name', 'emailLogs:id'])
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if (! empty($segmentId)) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Contact;
|
||||
|
||||
use App\Models\Email;
|
||||
use App\Models\Person\Person;
|
||||
|
||||
class EmailSelector
|
||||
{
|
||||
/**
|
||||
* Select the best email for a person following priority rules.
|
||||
* Priority:
|
||||
* 1) verified primary email that is active
|
||||
* 2) primary email that is active
|
||||
* 3) any active and valid email
|
||||
* 4) first active email
|
||||
*
|
||||
* Returns an array shape: ['email' => ?Email, 'reason' => ?string]
|
||||
*/
|
||||
public function selectForPerson(Person $person): array
|
||||
{
|
||||
$emails = Email::query()
|
||||
->where('person_id', $person->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('is_primary', 'desc')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($emails->isEmpty()) {
|
||||
return ['email' => null, 'reason' => 'no_active_emails'];
|
||||
}
|
||||
|
||||
// 1) verified primary
|
||||
$email = $emails->first(fn (Email $e) => $e->is_primary && $e->verified_at !== null);
|
||||
if ($email) {
|
||||
return ['email' => $email, 'reason' => null];
|
||||
}
|
||||
|
||||
// 2) primary (any verification)
|
||||
$email = $emails->first(fn (Email $e) => $e->is_primary);
|
||||
if ($email) {
|
||||
return ['email' => $email, 'reason' => null];
|
||||
}
|
||||
|
||||
// 3) valid (any)
|
||||
$email = $emails->first(fn (Email $e) => $e->valid);
|
||||
if ($email) {
|
||||
return ['email' => $email, 'reason' => null];
|
||||
}
|
||||
|
||||
// 4) first active
|
||||
return ['email' => $emails->first(), 'reason' => null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\DecisionEvents;
|
||||
|
||||
class ConditionEvaluator
|
||||
{
|
||||
/**
|
||||
* Returns true when ALL conditions pass (AND logic).
|
||||
*
|
||||
* Each condition: { field: string, operator: string, value: mixed }
|
||||
*
|
||||
* @param array<int, array{field: string, operator: string, value: mixed}> $conditions
|
||||
*/
|
||||
public function evaluate(array $conditions, DecisionEventContext $context): bool
|
||||
{
|
||||
foreach ($conditions as $condition) {
|
||||
if (! $this->evaluateOne($condition, $context)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function evaluateOne(array $condition, DecisionEventContext $context): bool
|
||||
{
|
||||
$field = $condition['field'] ?? '';
|
||||
$operator = $condition['operator'] ?? '=';
|
||||
$expected = $condition['value'] ?? null;
|
||||
|
||||
$actual = $this->resolveField($field, $context);
|
||||
|
||||
return $this->compare($actual, $operator, $expected);
|
||||
}
|
||||
|
||||
protected function resolveField(string $field, DecisionEventContext $context): mixed
|
||||
{
|
||||
return match ($field) {
|
||||
'activity.amount' => $context->activity?->amount,
|
||||
'activity.note' => $context->activity?->note,
|
||||
'contract.active' => $context->contract !== null ? (bool) $context->contract->active : null,
|
||||
'contract.account.balance_amount' => $this->resolveAccountBalance($context),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveAccountBalance(DecisionEventContext $context): mixed
|
||||
{
|
||||
if (! $context->contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$context->contract->loadMissing('account');
|
||||
|
||||
return $context->contract->account?->balance_amount;
|
||||
}
|
||||
|
||||
protected function compare(mixed $actual, string $operator, mixed $expected): bool
|
||||
{
|
||||
if ($actual === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array($operator, ['>', '>=', '<', '<='], true)) {
|
||||
$actual = (float) $actual;
|
||||
$expected = (float) $expected;
|
||||
}
|
||||
|
||||
return match ($operator) {
|
||||
'=' => $actual == $expected,
|
||||
'!=' => $actual != $expected,
|
||||
'>' => $actual > $expected,
|
||||
'>=' => $actual >= $expected,
|
||||
'<' => $actual < $expected,
|
||||
'<=' => $actual <= $expected,
|
||||
'contains' => str_contains((string) $actual, (string) $expected),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns available condition field definitions for the frontend.
|
||||
*
|
||||
* @return array<int, array{key: string, label: string, type: string}>
|
||||
*/
|
||||
public static function availableFields(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'activity.amount', 'label' => 'Aktivnost – znesek', 'type' => 'numeric'],
|
||||
['key' => 'activity.note', 'label' => 'Aktivnost – opomba', 'type' => 'string'],
|
||||
['key' => 'contract.active', 'label' => 'Pogodba – aktivna', 'type' => 'boolean'],
|
||||
['key' => 'contract.account.balance_amount', 'label' => 'Račun – stanje', 'type' => 'numeric'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns available operators grouped by field type.
|
||||
*
|
||||
* @return array<string, array<int, array{key: string, label: string}>>
|
||||
*/
|
||||
public static function availableOperators(): array
|
||||
{
|
||||
return [
|
||||
'numeric' => [
|
||||
['key' => '=', 'label' => 'je enako'],
|
||||
['key' => '!=', 'label' => 'ni enako'],
|
||||
['key' => '>', 'label' => 'je večje od'],
|
||||
['key' => '>=', 'label' => 'je večje ali enako'],
|
||||
['key' => '<', 'label' => 'je manjše od'],
|
||||
['key' => '<=', 'label' => 'je manjše ali enako'],
|
||||
],
|
||||
'string' => [
|
||||
['key' => '=', 'label' => 'je enako'],
|
||||
['key' => '!=', 'label' => 'ni enako'],
|
||||
['key' => 'contains', 'label' => 'vsebuje'],
|
||||
],
|
||||
'boolean' => [
|
||||
['key' => '=', 'label' => 'je'],
|
||||
['key' => '!=', 'label' => 'ni'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,14 @@ public function handle(DecisionEventContext $context, array $config = []): void
|
||||
$setting->reactivate = (bool) $config['reactivate'];
|
||||
}
|
||||
|
||||
// Cancel all active FieldJobs for this contract before archiving (raw update to avoid boot-event side effects)
|
||||
\DB::table('field_jobs')
|
||||
->where('contract_id', $contractId)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->whereNull('deleted_at')
|
||||
->update(['cancelled_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
$results = app(ArchiveExecutor::class)->executeSetting(
|
||||
$setting,
|
||||
['contract_id' => $contractId],
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\DecisionEvents\Handlers;
|
||||
|
||||
use App\Models\CallLater;
|
||||
use App\Services\DecisionEvents\Contracts\DecisionEventHandler;
|
||||
use App\Services\DecisionEvents\DecisionEventContext;
|
||||
|
||||
class CallLaterHandler implements DecisionEventHandler
|
||||
{
|
||||
public function handle(DecisionEventContext $context, array $config = []): void
|
||||
{
|
||||
$activity = $context->activity;
|
||||
|
||||
if (empty($activity->call_back_at)) {
|
||||
return;
|
||||
}
|
||||
|
||||
CallLater::create([
|
||||
'activity_id' => $activity->id,
|
||||
'client_case_id' => $activity->client_case_id,
|
||||
'contract_id' => $activity->contract_id,
|
||||
'user_id' => $activity->user_id,
|
||||
'call_back_at' => $activity->call_back_at,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,19 @@ class Registry
|
||||
'add_segment' => AddSegmentHandler::class,
|
||||
'archive_contract' => \App\Services\DecisionEvents\Handlers\ArchiveContractHandler::class,
|
||||
'end_field_job' => \App\Services\DecisionEvents\Handlers\EndFieldJobHandler::class,
|
||||
'add_call_later' => \App\Services\DecisionEvents\Handlers\CallLaterHandler::class,
|
||||
];
|
||||
|
||||
public static function resolve(string $key): DecisionEventHandler
|
||||
{
|
||||
$key = trim(strtolower($key));
|
||||
$class = static::$map[$key] ?? null;
|
||||
if (! $class || ! class_exists($class)) {
|
||||
if (! $class) {
|
||||
throw new InvalidArgumentException("Unknown decision event handler for key: {$key}");
|
||||
}
|
||||
if (! class_exists($class)) {
|
||||
throw new InvalidArgumentException("Handler class {$class} for key {$key} does not exist (check autoload)");
|
||||
}
|
||||
$handler = app($class);
|
||||
if (! $handler instanceof DecisionEventHandler) {
|
||||
throw new InvalidArgumentException("Handler for key {$key} must implement DecisionEventHandler");
|
||||
|
||||
@@ -152,19 +152,6 @@ public function sendFromLog(EmailLog $log): array
|
||||
$email->to(new Address($singleTo, (string) ($log->to_name ?? '')));
|
||||
}
|
||||
|
||||
// Always BCC the sender mailbox if present and not already in To
|
||||
$senderBcc = null;
|
||||
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
|
||||
// Check duplicates against toList
|
||||
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
|
||||
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
|
||||
$senderBcc = $fromAddr;
|
||||
$email->bcc(new Address($senderBcc));
|
||||
// Persist BCC for auditing
|
||||
$log->bcc = [$senderBcc];
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($text)) {
|
||||
$email->text($text);
|
||||
}
|
||||
@@ -304,10 +291,6 @@ public function sendFromLog(EmailLog $log): array
|
||||
}
|
||||
|
||||
$mailer->send($email);
|
||||
// Save log if we modified BCC
|
||||
if (! empty($log->getAttribute('bcc'))) {
|
||||
$log->save();
|
||||
}
|
||||
$headers = $email->getHeaders();
|
||||
$messageIdHeader = $headers->get('Message-ID');
|
||||
$messageId = $messageIdHeader ? $messageIdHeader->getBodyAsString() : null;
|
||||
@@ -330,15 +313,6 @@ public function sendFromLog(EmailLog $log): array
|
||||
$message->to($singleTo);
|
||||
}
|
||||
}
|
||||
// BCC the sender mailbox if resolvable and not already in To
|
||||
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
|
||||
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
|
||||
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
|
||||
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
|
||||
$message->bcc($fromAddr);
|
||||
$log->bcc = [$fromAddr];
|
||||
}
|
||||
}
|
||||
$message->subject($subject);
|
||||
if (! empty($log->reply_to)) {
|
||||
$message->replyTo($log->reply_to);
|
||||
@@ -464,15 +438,6 @@ public function sendFromLog(EmailLog $log): array
|
||||
$message->to($singleTo);
|
||||
}
|
||||
}
|
||||
// BCC the sender mailbox if resolvable and not already in To
|
||||
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
|
||||
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
|
||||
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
|
||||
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
|
||||
$message->bcc($fromAddr);
|
||||
$log->bcc = [$fromAddr];
|
||||
}
|
||||
}
|
||||
$message->subject($subject);
|
||||
if (! empty($log->reply_to)) {
|
||||
$message->replyTo($log->reply_to);
|
||||
|
||||
@@ -30,17 +30,49 @@ public function render(array $template, array $ctx): array
|
||||
return preg_replace_callback('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', function ($m) use ($map) {
|
||||
$key = $m[1];
|
||||
|
||||
return (string) data_get($map, $key, '');
|
||||
// body_text is handled separately by applyBodyText(); preserve as literal
|
||||
if ($key === 'body_text') {
|
||||
return $m[0];
|
||||
}
|
||||
|
||||
$value = data_get($map, $key, '');
|
||||
|
||||
// If the resolved value is an array (e.g. {{ contract.meta }} used directly),
|
||||
// return empty string instead of triggering "Array to string conversion".
|
||||
if (is_array($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}, $input);
|
||||
};
|
||||
|
||||
$bodyText = isset($ctx['body_text']) ? (string) $ctx['body_text'] : '';
|
||||
|
||||
return [
|
||||
'subject' => $replacer($template['subject']) ?? '',
|
||||
'html' => $replacer($template['html'] ?? null) ?? null,
|
||||
'text' => $replacer($template['text'] ?? null) ?? null,
|
||||
'html' => $this->applyBodyText($replacer($template['html'] ?? null) ?? null, $bodyText, html: true),
|
||||
'text' => $this->applyBodyText($replacer($template['text'] ?? null) ?? null, $bodyText, html: false),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute the literal {{body_text}} placeholder with the user-supplied body text.
|
||||
* In HTML context the text is HTML-escaped and newlines are converted to <br>.
|
||||
* In plain-text context the raw value is used.
|
||||
*/
|
||||
public function applyBodyText(?string $content, string $bodyText, bool $html = true): ?string
|
||||
{
|
||||
if ($content === null) {
|
||||
return null;
|
||||
}
|
||||
$replacement = $html
|
||||
? nl2br(htmlspecialchars($bodyText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'))
|
||||
: $bodyText;
|
||||
|
||||
return preg_replace('/{{\s*body_text\s*}}/', $replacement, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx
|
||||
*/
|
||||
@@ -145,12 +177,18 @@ protected function buildMap(array $ctx): array
|
||||
'id' => data_get($co, 'id'),
|
||||
'uuid' => data_get($co, 'uuid'),
|
||||
'reference' => data_get($co, 'reference'),
|
||||
// Format amounts in EU style for emails
|
||||
'amount' => $formatMoneyEu(data_get($co, 'amount')),
|
||||
// Account amounts — sourced from the related Account model
|
||||
'account' => [
|
||||
'balance_amount' => $formatMoneyEu(data_get($co, 'account.balance_amount')),
|
||||
'initial_amount' => $formatMoneyEu(data_get($co, 'account.initial_amount')),
|
||||
],
|
||||
];
|
||||
$meta = data_get($co, 'meta');
|
||||
if (is_string($meta)) {
|
||||
$meta = json_decode($meta, true) ?? [];
|
||||
}
|
||||
if (is_array($meta)) {
|
||||
$out['contract']['meta'] = $meta;
|
||||
$out['contract']['meta'] = $this->flattenMetaForTemplate($meta);
|
||||
}
|
||||
}
|
||||
if (isset($ctx['activity'])) {
|
||||
@@ -172,7 +210,50 @@ protected function buildMap(array $ctx): array
|
||||
if (! empty($ctx['extra']) && is_array($ctx['extra'])) {
|
||||
$out['extra'] = $ctx['extra'];
|
||||
}
|
||||
if (isset($ctx['mail_profile'])) {
|
||||
$mp = $ctx['mail_profile'];
|
||||
$out['profile'] = [
|
||||
'signature' => is_array($mp->signature) ? $mp->signature : [],
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a contract meta array so every leaf value is accessible by its bare key.
|
||||
*
|
||||
* Handles three formats stored in the wild:
|
||||
* 1. Numeric wrapper: { "1": { "sklic": "SI00…", "job_days": 1 } }
|
||||
* → { "sklic": "SI00…", "job_days": 1 }
|
||||
* 2. Structured entry: { "sklic": { "value": "SI00…", "type": "string" } }
|
||||
* → { "sklic": "SI00…" }
|
||||
* 3. Already flat: { "sklic": "SI00…" }
|
||||
* → { "sklic": "SI00…" }
|
||||
*/
|
||||
private function flattenMetaForTemplate(array $meta): array
|
||||
{
|
||||
$flat = [];
|
||||
foreach ($meta as $key => $item) {
|
||||
if (!is_array($item)) {
|
||||
// Plain scalar — keep as-is (format 3)
|
||||
if (!array_key_exists($key, $flat)) {
|
||||
$flat[$key] = $item;
|
||||
}
|
||||
} elseif (array_key_exists('value', $item)) {
|
||||
// Structured { value, type, title } entry (format 2)
|
||||
$flat[$key] = $item['value'];
|
||||
} elseif (is_numeric($key)) {
|
||||
// Numeric wrapper key — recurse and alias without the prefix (format 1)
|
||||
foreach ($this->flattenMetaForTemplate($item) as $nk => $nv) {
|
||||
if (!array_key_exists($nk, $flat)) {
|
||||
$flat[$nk] = $nv;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Non-numeric nested arrays without a 'value' key are silently skipped
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ public function process(Import $import, array $mapped, array $raw, array $contex
|
||||
$payload = $this->buildPayloadForAddress($address);
|
||||
$payload['person_id'] = $personId;
|
||||
|
||||
$addressEntity = new \App\Models\Person\PersonAddress;
|
||||
$addressEntity = new PersonAddress;
|
||||
$addressEntity->fill($payload);
|
||||
$addressEntity->save();
|
||||
|
||||
@@ -129,7 +129,7 @@ public function process(Import $import, array $mapped, array $raw, array $contex
|
||||
|
||||
protected function resolveAddress(string $address, int $personId): mixed
|
||||
{
|
||||
return \App\Models\Person\PersonAddress::where('person_id', $personId)
|
||||
return PersonAddress::where('person_id', $personId)
|
||||
->where('address', $address)
|
||||
->first();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
use App\Models\Person\PersonPhone;
|
||||
use App\Models\Person\PersonType;
|
||||
use App\Models\Person\PhoneType;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -1632,7 +1633,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
|
||||
$existing = Account::query()
|
||||
->where('contract_id', $contractId)
|
||||
->where('reference', $reference)
|
||||
//->where('reference', $reference)
|
||||
->where('active', 1)
|
||||
->first();
|
||||
|
||||
@@ -1655,6 +1656,10 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
$value = $acc[$field] ?? null;
|
||||
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
|
||||
$value = $this->normalizeDecimal($value);
|
||||
// Ensure the normalized value is numeric, otherwise default to 0
|
||||
if ($value === '' || $value === '-' || ! is_numeric($value)) {
|
||||
$value = 0;
|
||||
}
|
||||
}
|
||||
// Convert empty string to 0 for amount fields
|
||||
if (in_array($field, ['balance_amount', 'initial_amount'], true) && ($value === '' || $value === null)) {
|
||||
@@ -1688,8 +1693,12 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
if ($existing) {
|
||||
// Build non-null changes for account fields
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
// Track balance change
|
||||
$oldBalance = (float) ($existing->balance_amount ?? 0);
|
||||
// Track balance change - normalize in case DB has malformed data
|
||||
$rawBalance = $existing->balance_amount ?? 0;
|
||||
if (is_string($rawBalance) && $rawBalance !== '') {
|
||||
$rawBalance = $this->normalizeDecimal($rawBalance);
|
||||
}
|
||||
$oldBalance = is_numeric($rawBalance) ? (float) $rawBalance : 0;
|
||||
// Note: meta merging for contracts is handled in upsertContractChain, not here
|
||||
if (! empty($changes)) {
|
||||
$existing->fill($changes);
|
||||
@@ -1698,7 +1707,11 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
|
||||
// If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
|
||||
if (array_key_exists('balance_amount', $changes)) {
|
||||
$newBalance = (float) ($existing->balance_amount ?? 0);
|
||||
$rawNewBalance = $existing->balance_amount ?? 0;
|
||||
if (is_string($rawNewBalance) && $rawNewBalance !== '') {
|
||||
$rawNewBalance = $this->normalizeDecimal($rawNewBalance);
|
||||
}
|
||||
$newBalance = is_numeric($rawNewBalance) ? (float) $rawNewBalance : 0;
|
||||
if ($newBalance !== $oldBalance) {
|
||||
try {
|
||||
$contractId = $existing->contract_id;
|
||||
@@ -2974,7 +2987,7 @@ private function findOrCreatePersonId(array $p): ?int
|
||||
// Create person if any fields present; ensure required foreign keys
|
||||
if (! empty($p)) {
|
||||
$data = [];
|
||||
foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id'] as $k) {
|
||||
foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id', 'employer'] as $k) {
|
||||
if (array_key_exists($k, $p)) {
|
||||
$data[$k] = $p[$k];
|
||||
}
|
||||
@@ -2987,6 +3000,16 @@ private function findOrCreatePersonId(array $p): ?int
|
||||
$data['full_name'] = trim($fn.' '.$ln);
|
||||
}
|
||||
}
|
||||
|
||||
// normalise birthday date
|
||||
if (!empty($data['birthday'])) {
|
||||
try {
|
||||
$data['birthday'] = date('Y-m-d', strtotime($data['birthday']));
|
||||
} catch (Exception $e) {
|
||||
Log::warning('ImportProcessor::findOrCreatePersonId ' . $e->getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
// ensure required group/type ids
|
||||
$data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId();
|
||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId();
|
||||
@@ -3163,10 +3186,38 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
||||
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
|
||||
$addrData['country'] = 'SLO';
|
||||
}
|
||||
|
||||
if (!empty($addrData['city']) && empty($addrData['post_code'])) {
|
||||
if (preg_match('/^\d{3,}\s+/',trim($addrData['city']))) {
|
||||
$cleanStrCity = str($addrData['city'])->squish()->value();
|
||||
$splitCity = preg_split('/\s/', $cleanStrCity, 2);
|
||||
if (count($splitCity) >= 2) {
|
||||
$addrData['post_code'] = $splitCity[0];
|
||||
$addrData['city'] = $splitCity[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Compare addresses with all spaces removed to handle whitespace variations
|
||||
$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
|
||||
/*$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
|
||||
|
||||
|
||||
$existing = PersonAddress::where('person_id', $personId)
|
||||
->whereRaw("REPLACE(address, ' ', '') = ?", [$addressLineNoSpaces])
|
||||
->first();*/
|
||||
|
||||
// Build search query combining address, post_code and city
|
||||
$searchParts = [$addrData['address']];
|
||||
if (!empty($addrData['post_code'])) {
|
||||
$searchParts[] = $addrData['post_code'];
|
||||
}
|
||||
if (!empty($addrData['city'])) {
|
||||
$searchParts[] = $addrData['city'];
|
||||
}
|
||||
|
||||
$searchQuery = implode(' ', $searchParts);
|
||||
// Use fulltext search (GIN index optimized)
|
||||
$existing = PersonAddress::query()->where('person_id', $personId)
|
||||
->whereRaw("search_vector @@ plainto_tsquery('simple', ?)", [$searchQuery])
|
||||
->first();
|
||||
|
||||
$applyInsert = [];
|
||||
@@ -3211,6 +3262,11 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
||||
$data['person_id'] = $personId;
|
||||
$data['country'] = $data['country'] ?? 'SLO';
|
||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId();
|
||||
|
||||
if (!empty($addrData['post_code']) && $addrData['post_code'] !== '0' && !isset($applyUpdate['post_code'])) {
|
||||
$data['post_code'] = $addrData['post_code'];
|
||||
}
|
||||
|
||||
try {
|
||||
$created = PersonAddress::create($data);
|
||||
|
||||
|
||||
+4
-4
@@ -10,21 +10,21 @@
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"diglactic/laravel-breadcrumbs": "^10.0",
|
||||
"http-interop/http-factory-guzzle": "^1.2",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/framework": "12.0",
|
||||
"inertiajs/inertia-laravel": "^3.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/jetstream": "^5.2",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/scout": "^10.11",
|
||||
"laravel/tinker": "^2.9",
|
||||
"maatwebsite/excel": "^3.1",
|
||||
"meilisearch/meilisearch-php": "^1.11",
|
||||
"robertboes/inertia-breadcrumbs": "dev-laravel-12",
|
||||
"robertboes/inertia-breadcrumbs": "^1.0",
|
||||
"tightenco/ziggy": "^2.0",
|
||||
"tijsverkoyen/css-to-inline-styles": "^2.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/boost": "^1.1",
|
||||
"laravel/boost": "^2.2",
|
||||
"laravel/pint": "^1.13",
|
||||
"laravel/sail": "^1.26",
|
||||
"mockery/mockery": "^1.6",
|
||||
|
||||
Generated
+1031
-746
File diff suppressed because it is too large
Load Diff
@@ -60,7 +60,7 @@
|
||||
'features' => [
|
||||
// Features::termsAndPrivacyPolicy(),
|
||||
// Features::profilePhotos(),
|
||||
Features::api(),
|
||||
// Features::api(),
|
||||
// Features::teams(['invitations' => true]),
|
||||
Features::accountDeletion(),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('person', function (Blueprint $table){
|
||||
$table->string('employer', 125)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('person', function (Blueprint $table){
|
||||
$table->dropColumn('employer');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Add a generated tsvector column for fulltext search
|
||||
DB::statement("
|
||||
ALTER TABLE person_addresses
|
||||
ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
to_tsvector('simple',
|
||||
coalesce(address, '') || ' ' ||
|
||||
coalesce(post_code, '') || ' ' ||
|
||||
coalesce(city, '')
|
||||
)
|
||||
) STORED
|
||||
");
|
||||
|
||||
// Create GIN index on the tsvector column for fast fulltext search
|
||||
DB::statement('CREATE INDEX person_addresses_search_vector_idx ON person_addresses USING GIN(search_vector)');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('person_addresses', function (Blueprint $table) {
|
||||
$table->dropIndex('person_addresses_search_vector_idx');
|
||||
$table->dropColumn('search_vector');
|
||||
});
|
||||
}
|
||||
};
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('person_addresses', function (Blueprint $table) {
|
||||
$table->dropIndex('person_addresses_search_vector_idx');
|
||||
$table->dropColumn('search_vector');
|
||||
|
||||
|
||||
$table->string('post_code', 50)->nullable()->change();
|
||||
|
||||
});
|
||||
|
||||
// Add a generated tsvector column for fulltext search
|
||||
DB::statement("
|
||||
ALTER TABLE person_addresses
|
||||
ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
to_tsvector('simple',
|
||||
coalesce(address, '') || ' ' ||
|
||||
coalesce(post_code, '') || ' ' ||
|
||||
coalesce(city, '')
|
||||
)
|
||||
) STORED
|
||||
");
|
||||
|
||||
// Create GIN index on the tsvector column for fast fulltext search
|
||||
DB::statement('CREATE INDEX person_addresses_search_vector_idx ON person_addresses USING GIN(search_vector)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('person_addresses', function (Blueprint $table) {
|
||||
$table->string('post_code', 20)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
$table->dateTime('call_back_at')->nullable()->after('due_date');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
$table->dropColumn('call_back_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('call_laters', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('activity_id')->constrained('activities')->cascadeOnDelete();
|
||||
$table->foreignId('client_case_id')->constrained('client_cases')->cascadeOnDelete();
|
||||
$table->foreignId('contract_id')->nullable()->constrained('contracts')->nullOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->dateTime('call_back_at');
|
||||
$table->dateTime('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('call_laters');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('installments', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('account_id')->constrained('accounts')->cascadeOnDelete();
|
||||
$table->decimal('amount', 20, 4);
|
||||
$table->decimal('balance_before', 20, 4)->nullable();
|
||||
$table->string('currency', 3)->default('EUR');
|
||||
$table->string('reference', 100)->nullable();
|
||||
$table->timestamp('installment_at')->nullable();
|
||||
$table->json('meta')->nullable();
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('activity_id')->nullable()->constrained('activities')->nullOnDelete();
|
||||
$table->softDeletes();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('installments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('installment_settings', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('default_currency', 3)->default('EUR');
|
||||
$table->boolean('create_activity_on_installment')->default(false);
|
||||
$table->foreignId('default_decision_id')->nullable()->constrained('decisions')->nullOnDelete();
|
||||
$table->foreignId('default_action_id')->nullable()->constrained('actions')->nullOnDelete();
|
||||
$table->string('activity_note_template', 255)->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('installment_settings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('contract_settings', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->boolean('create_activity_on_balance_change')->default(false);
|
||||
$table->foreignId('default_action_id')->nullable()->constrained('actions')->nullOnDelete();
|
||||
$table->foreignId('default_decision_id')->nullable()->constrained('decisions')->nullOnDelete();
|
||||
$table->string('activity_note_template', 255)->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('contract_settings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE field_jobs ALTER COLUMN assigned_at TYPE timestamp USING assigned_at::timestamp');
|
||||
DB::statement('ALTER TABLE field_jobs ALTER COLUMN completed_at TYPE timestamp USING completed_at::timestamp');
|
||||
DB::statement('ALTER TABLE field_jobs ALTER COLUMN cancelled_at TYPE timestamp USING cancelled_at::timestamp');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE field_jobs ALTER COLUMN assigned_at TYPE date USING assigned_at::date');
|
||||
DB::statement('ALTER TABLE field_jobs ALTER COLUMN completed_at TYPE date USING completed_at::date');
|
||||
DB::statement('ALTER TABLE field_jobs ALTER COLUMN cancelled_at TYPE date USING cancelled_at::date');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('login_redirect')->nullable()->after('active');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('login_redirect');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('mail_profiles', function (Blueprint $table) {
|
||||
$table->jsonb('signature')->nullable()->after('priority');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('mail_profiles', function (Blueprint $table) {
|
||||
$table->dropColumn('signature');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('email_templates', function (Blueprint $table): void {
|
||||
$table->foreignId('action_id')->nullable()->after('active')->constrained('actions')->nullOnDelete();
|
||||
$table->foreignId('decision_id')->nullable()->after('action_id')->constrained('decisions')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('email_templates', function (Blueprint $table): void {
|
||||
$table->dropForeign(['action_id']);
|
||||
$table->dropForeign(['decision_id']);
|
||||
$table->dropColumn(['action_id', 'decision_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('mail_profiles', function (Blueprint $table): void {
|
||||
$table->boolean('auto_mailer')->default(false)->after('active');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('mail_profiles', function (Blueprint $table): void {
|
||||
$table->dropColumn('auto_mailer');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('activity_email_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('activity_id')->constrained('activities')->cascadeOnDelete();
|
||||
$table->foreignId('email_log_id')->constrained('email_logs')->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['activity_id', 'email_log_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('activity_email_logs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('emails', function (Blueprint $table) {
|
||||
$table->boolean('failed')->default(false)->after('valid');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('emails', function (Blueprint $table) {
|
||||
$table->dropColumn('failed');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('email_templates', function (Blueprint $table): void {
|
||||
$table->boolean('client')->default(false)->after('active');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('email_templates', function (Blueprint $table): void {
|
||||
$table->dropColumn('client');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -31,6 +31,11 @@ public function run(): void
|
||||
'name' => 'End field job',
|
||||
'description' => 'Dispatches a queued job to finalize field-related processing (implementation-specific).',
|
||||
],
|
||||
[
|
||||
'key' => 'add_call_later',
|
||||
'name' => 'Klic kasneje',
|
||||
'description' => 'Ustvari zapis za povratni klic ob določenem datumu in uri.',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
|
||||
@@ -14,7 +14,7 @@ public function run(): void
|
||||
'key' => 'person',
|
||||
'canonical_root' => 'person',
|
||||
'label' => 'Person',
|
||||
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'],
|
||||
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description', 'employer'],
|
||||
'field_aliases' => [
|
||||
'dob' => 'birthday',
|
||||
'date_of_birth' => 'birthday',
|
||||
@@ -30,6 +30,7 @@ public function run(): void
|
||||
['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'],
|
||||
['pattern' => '/^(rojstvo|datum\s*rojstva|dob|birth|birthday|date\s*of\s*birth)\b/i', 'field' => 'birthday'],
|
||||
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
|
||||
['pattern' => '/^(delodajalec|služba)\b/i', 'field' => 'employer']
|
||||
],
|
||||
'ui' => ['order' => 1],
|
||||
],
|
||||
|
||||
@@ -21,6 +21,7 @@ public function run(): void
|
||||
$this->seedSegmentActivityCountsReport();
|
||||
$this->seedActionsDecisionsCountReport();
|
||||
$this->seedActivitiesPerPeriodReport();
|
||||
$this->seedActivitiesDetailReport();
|
||||
}
|
||||
|
||||
protected function seedActiveContractsReport(): void
|
||||
@@ -783,4 +784,265 @@ protected function seedActivitiesPerPeriodReport(): void
|
||||
'order' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function seedActivitiesDetailReport(): void
|
||||
{
|
||||
$report = Report::create([
|
||||
'slug' => 'activities-detail',
|
||||
'name' => 'Aktivnosti – pregled',
|
||||
'description' => 'Podroben pregled aktivnosti z možnostjo filtriranja po stranki, datumu, akciji in odločitvi.',
|
||||
'category' => 'activities',
|
||||
'enabled' => true,
|
||||
'order' => 7,
|
||||
]);
|
||||
|
||||
// Entities (joins)
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\Activity',
|
||||
'join_type' => 'base',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\Action',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'activities.action_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'actions.id',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\Decision',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'activities.decision_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'decisions.id',
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\Contract',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'activities.contract_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'contracts.id',
|
||||
'order' => 3,
|
||||
]);
|
||||
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\ClientCase',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'activities.client_case_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'client_cases.id',
|
||||
'order' => 4,
|
||||
]);
|
||||
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\Client',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'client_cases.client_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'clients.id',
|
||||
'order' => 5,
|
||||
]);
|
||||
|
||||
$report->entities()->createMany([
|
||||
[
|
||||
'model_class' => 'App\\Models\\Person\\Person',
|
||||
'alias' => 'client_people',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'clients.person_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'client_people.id',
|
||||
'order' => 6,
|
||||
],
|
||||
[
|
||||
'model_class' => 'App\\Models\\Person\\Person',
|
||||
'alias' => 'subject_people',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'client_cases.person_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'subject_people.id',
|
||||
'order' => 7,
|
||||
],
|
||||
]);
|
||||
|
||||
// Columns
|
||||
$report->columns()->createMany([
|
||||
[
|
||||
'key' => 'contract_reference',
|
||||
'label' => 'Pogodba',
|
||||
'type' => 'string',
|
||||
'expression' => 'contracts.reference',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'key' => 'naziv',
|
||||
'label' => 'Naziv',
|
||||
'type' => 'string',
|
||||
'expression' => 'subject_people.full_name',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 1,
|
||||
],
|
||||
[
|
||||
'key' => 'stranka',
|
||||
'label' => 'Stranka',
|
||||
'type' => 'string',
|
||||
'expression' => 'client_people.full_name',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 2,
|
||||
],
|
||||
[
|
||||
'key' => 'aktivnost',
|
||||
'label' => 'Aktivnost',
|
||||
'type' => 'string',
|
||||
'expression' => "CONCAT(COALESCE(actions.name, ''), ' / ', COALESCE(decisions.name, ''))",
|
||||
'sortable' => false,
|
||||
'visible' => true,
|
||||
'order' => 3,
|
||||
],
|
||||
[
|
||||
'key' => 'datum',
|
||||
'label' => 'Datum',
|
||||
'type' => 'date',
|
||||
'expression' => 'DATE(activities.created_at)',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 4,
|
||||
],
|
||||
[
|
||||
'key' => 'opomba',
|
||||
'label' => 'Opomba',
|
||||
'type' => 'string',
|
||||
'expression' => 'activities.note',
|
||||
'sortable' => false,
|
||||
'visible' => true,
|
||||
'order' => 5,
|
||||
],
|
||||
[
|
||||
'key' => 'zapadlost',
|
||||
'label' => 'Zapadlost',
|
||||
'type' => 'date',
|
||||
'expression' => 'activities.due_date',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 6,
|
||||
],
|
||||
[
|
||||
'key' => 'znesek',
|
||||
'label' => 'Znesek',
|
||||
'type' => 'currency',
|
||||
'expression' => 'activities.amount',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 7,
|
||||
],
|
||||
]);
|
||||
|
||||
// Filters
|
||||
$report->filters()->createMany([
|
||||
[
|
||||
'key' => 'client_uuid',
|
||||
'label' => 'Stranka',
|
||||
'type' => 'select:client',
|
||||
'nullable' => true,
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'key' => 'from',
|
||||
'label' => 'Datum od',
|
||||
'type' => 'date',
|
||||
'nullable' => true,
|
||||
'order' => 1,
|
||||
],
|
||||
[
|
||||
'key' => 'to',
|
||||
'label' => 'Datum do',
|
||||
'type' => 'date',
|
||||
'nullable' => true,
|
||||
'order' => 2,
|
||||
],
|
||||
[
|
||||
'key' => 'action_id',
|
||||
'label' => 'Akcija',
|
||||
'type' => 'select:action',
|
||||
'nullable' => true,
|
||||
'order' => 3,
|
||||
],
|
||||
[
|
||||
'key' => 'decision_id',
|
||||
'label' => 'Odločitev',
|
||||
'type' => 'select:decision',
|
||||
'nullable' => true,
|
||||
'order' => 4,
|
||||
],
|
||||
]);
|
||||
|
||||
// Conditions (all filter-based, skipped when null)
|
||||
$report->conditions()->createMany([
|
||||
[
|
||||
'column' => 'activities.created_at',
|
||||
'operator' => '>=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'from',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 1,
|
||||
'order' => 0,
|
||||
'enabled' => true,
|
||||
],
|
||||
[
|
||||
'column' => 'activities.created_at',
|
||||
'operator' => '<=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'to',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 1,
|
||||
'order' => 1,
|
||||
'enabled' => true,
|
||||
],
|
||||
[
|
||||
'column' => 'clients.uuid',
|
||||
'operator' => '=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'client_uuid',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 2,
|
||||
'order' => 0,
|
||||
'enabled' => true,
|
||||
],
|
||||
[
|
||||
'column' => 'activities.action_id',
|
||||
'operator' => '=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'action_id',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 3,
|
||||
'order' => 0,
|
||||
'enabled' => true,
|
||||
],
|
||||
[
|
||||
'column' => 'activities.decision_id',
|
||||
'operator' => '=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'decision_id',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 4,
|
||||
'order' => 0,
|
||||
'enabled' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
// Order
|
||||
$report->orders()->create([
|
||||
'column' => 'activities.created_at',
|
||||
'direction' => 'DESC',
|
||||
'order' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Teren App Deployment Script
|
||||
# This script handles automated deployment from Gitea
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
echo "🚀 Starting deployment..."
|
||||
|
||||
# Configuration
|
||||
PROJECT_DIR="/var/www/Teren-app"
|
||||
BRANCH="main" # Change to your production branch
|
||||
GITEA_REPO="git@your-gitea-server.com:username/Teren-app.git"
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Change to project directory
|
||||
cd $PROJECT_DIR
|
||||
|
||||
echo "📥 Pulling latest changes from $BRANCH..."
|
||||
git fetch origin $BRANCH
|
||||
git reset --hard origin/$BRANCH
|
||||
|
||||
echo "🔧 Copying production environment file..."
|
||||
if [ ! -f .env ]; then
|
||||
echo "${RED}❌ .env file not found! Please create it from .env.production.example${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🐳 Building and starting Docker containers..."
|
||||
docker-compose down
|
||||
docker-compose build --no-cache app
|
||||
docker-compose up -d
|
||||
|
||||
echo "⏳ Waiting for containers to be healthy..."
|
||||
sleep 10
|
||||
|
||||
echo "📦 Installing/updating Composer dependencies..."
|
||||
docker-compose exec -T app composer install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
echo "🎨 Building frontend assets..."
|
||||
# If you build assets locally or in CI/CD, uncomment:
|
||||
# npm ci
|
||||
# npm run build
|
||||
|
||||
echo "🔑 Optimizing Laravel..."
|
||||
docker-compose exec -T app php artisan config:cache
|
||||
docker-compose exec -T app php artisan route:cache
|
||||
docker-compose exec -T app php artisan view:cache
|
||||
docker-compose exec -T app php artisan event:cache
|
||||
|
||||
echo "📊 Running database migrations..."
|
||||
docker-compose exec -T app php artisan migrate --force
|
||||
|
||||
echo "🗄️ Clearing old caches..."
|
||||
docker-compose exec -T app php artisan cache:clear
|
||||
docker-compose exec -T app php artisan queue:restart
|
||||
|
||||
echo "🔄 Restarting queue workers..."
|
||||
docker-compose restart app
|
||||
|
||||
echo "${GREEN}✅ Deployment completed successfully!${NC}"
|
||||
|
||||
# Optional: Send notification (Slack, Discord, etc.)
|
||||
# curl -X POST -H 'Content-type: application/json' \
|
||||
# --data '{"text":"🚀 Teren App deployed successfully!"}' \
|
||||
# YOUR_WEBHOOK_URL
|
||||
|
||||
# Show running containers
|
||||
echo "📋 Running containers:"
|
||||
docker-compose ps
|
||||
@@ -0,0 +1,189 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Laravel Application
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- PHP_VERSION=8.4
|
||||
container_name: teren-app
|
||||
restart: unless-stopped
|
||||
working_dir: /var/www
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
- ./storage:/var/www/storage
|
||||
- ./bootstrap/cache:/var/www/bootstrap/cache
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-production}
|
||||
- APP_DEBUG=${APP_DEBUG:-false}
|
||||
- DB_CONNECTION=pgsql
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_DATABASE=${DB_DATABASE}
|
||||
- DB_USERNAME=${DB_USERNAME}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- QUEUE_CONNECTION=redis
|
||||
- LIBREOFFICE_BIN=/usr/bin/soffice
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- teren-network
|
||||
# Supervisor runs inside the container (defined in Dockerfile)
|
||||
# Includes PHP-FPM, Laravel queue workers, and queue-sms workers
|
||||
|
||||
# Nginx Web Server (VPN-only access)
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: teren-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "10.13.13.1:80:80" # Only accessible via WireGuard VPN
|
||||
- "10.13.13.1:443:443" # Only accessible via WireGuard VPN
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d
|
||||
- ./docker/nginx/ssl:/etc/nginx/ssl
|
||||
- ./docker/certbot/conf:/etc/letsencrypt
|
||||
- ./docker/certbot/www:/var/www/certbot
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
- teren-network
|
||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||
|
||||
# Certbot for SSL certificates
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
container_name: teren-certbot
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./docker/certbot/conf:/etc/letsencrypt
|
||||
- ./docker/certbot/www:/var/www/certbot
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
networks:
|
||||
- teren-network
|
||||
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: teren-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432" # Only accessible via localhost (or VPN)
|
||||
environment:
|
||||
- POSTGRES_DB=${DB_DATABASE}
|
||||
- POSTGRES_USER=${DB_USERNAME}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./docker/postgres/init:/docker-entrypoint-initdb.d
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- teren-network
|
||||
|
||||
# pgAdmin - PostgreSQL UI
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: teren-pgadmin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5050:80" # Only accessible via localhost (or VPN)
|
||||
environment:
|
||||
- PGADMIN_DEFAULT_EMAIL=${PGADMIN_EMAIL:-admin@admin.com}
|
||||
- PGADMIN_DEFAULT_PASSWORD=${PGADMIN_PASSWORD:-admin}
|
||||
- PGADMIN_CONFIG_SERVER_MODE=True
|
||||
- PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED=True
|
||||
volumes:
|
||||
- pgadmin-data:/var/lib/pgadmin
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- teren-network
|
||||
|
||||
# Redis for caching and queues
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: teren-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- teren-network
|
||||
|
||||
# WireGuard VPN with Web UI Dashboard
|
||||
wireguard:
|
||||
image: weejewel/wg-easy:latest
|
||||
container_name: teren-wireguard
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
environment:
|
||||
- WG_HOST=${WG_SERVERURL} # Your VPS public IP or domain
|
||||
- PASSWORD=${WG_UI_PASSWORD} # Password for WireGuard UI
|
||||
- WG_PORT=51820
|
||||
- WG_DEFAULT_ADDRESS=10.13.13.x
|
||||
- WG_DEFAULT_DNS=1.1.1.1,1.0.0.1
|
||||
- WG_MTU=1420
|
||||
- WG_PERSISTENT_KEEPALIVE=25
|
||||
- WG_ALLOWED_IPS=10.13.13.0/24
|
||||
volumes:
|
||||
- wireguard-data:/etc/wireguard
|
||||
ports:
|
||||
- "51820:51820/udp" # WireGuard VPN port (public)
|
||||
- "51821:51821/tcp" # WireGuard Web UI (public for initial setup, then VPN-only)
|
||||
sysctls:
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
- net.ipv4.ip_forward=1
|
||||
networks:
|
||||
- teren-network
|
||||
|
||||
# Portainer - Docker Management UI (VPN-only access)
|
||||
portainer:
|
||||
image: portainer/portainer-ce:latest
|
||||
container_name: teren-portainer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "10.13.13.1:9000:9000" # Portainer UI (VPN-only)
|
||||
- "10.13.13.1:9443:9443" # Portainer HTTPS (VPN-only)
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- portainer-data:/data
|
||||
networks:
|
||||
- teren-network
|
||||
|
||||
networks:
|
||||
teren-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
driver: local
|
||||
pgadmin-data:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
wireguard-data:
|
||||
driver: local
|
||||
portainer-data:
|
||||
driver: local
|
||||
@@ -0,0 +1,86 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name example.com www.example.com; # Change this to your domain
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name example.com www.example.com; # Change this to your domain
|
||||
|
||||
root /var/www/public;
|
||||
index index.php index.html;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # Change this
|
||||
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # Change this
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Laravel location configuration
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
fastcgi_hide_header X-Powered-By;
|
||||
|
||||
# Increase timeouts for long-running requests
|
||||
fastcgi_read_timeout 300;
|
||||
fastcgi_send_timeout 300;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Deny access to sensitive files
|
||||
location ~ /\.env {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
|
||||
gzip_disable "msie6";
|
||||
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /var/www/public;
|
||||
index index.php index.html;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Laravel location configuration
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
fastcgi_hide_header X-Powered-By;
|
||||
|
||||
# Increase timeouts for debugging
|
||||
fastcgi_read_timeout 3600;
|
||||
fastcgi_send_timeout 3600;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Deny access to sensitive files
|
||||
location ~ /\.env {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
|
||||
gzip_disable "msie6";
|
||||
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
; PHP Custom Configuration for Production
|
||||
|
||||
upload_max_filesize = 100M
|
||||
post_max_size = 100M
|
||||
memory_limit = 512M
|
||||
max_execution_time = 300
|
||||
max_input_time = 300
|
||||
|
||||
; OPcache settings
|
||||
opcache.enable = 1
|
||||
opcache.memory_consumption = 256
|
||||
opcache.interned_strings_buffer = 16
|
||||
opcache.max_accelerated_files = 20000
|
||||
opcache.validate_timestamps = 0
|
||||
opcache.save_comments = 1
|
||||
opcache.fast_shutdown = 1
|
||||
|
||||
; Production settings
|
||||
expose_php = Off
|
||||
display_errors = Off
|
||||
display_startup_errors = Off
|
||||
log_errors = On
|
||||
error_log = /var/log/php_errors.log
|
||||
@@ -0,0 +1,25 @@
|
||||
[program:laravel-queue]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=/usr/local/bin/php /var/www/artisan queue:work --sleep=3 --tries=3 --timeout=300 --verbose
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user=www
|
||||
numprocs=2
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/www/storage/logs/worker.log
|
||||
stdout_logfile_maxbytes=20MB
|
||||
stdout_logfile_backups=10
|
||||
stopwaitsecs=360
|
||||
|
||||
[program:laravel-queue-sms]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=/usr/local/bin/php /var/www/artisan queue:work --queue=sms --sleep=3 --tries=3 --timeout=90 --verbose
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user=www
|
||||
numprocs=1
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/www/storage/logs/worker-sms.log
|
||||
stdout_logfile_maxbytes=20MB
|
||||
stdout_logfile_backups=10
|
||||
stopwaitsecs=360
|
||||
@@ -0,0 +1,11 @@
|
||||
[program:php-fpm]
|
||||
command=/usr/local/sbin/php-fpm --nodaemonize --fpm-config /usr/local/etc/php-fpm.d/www.conf
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=5
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stdout_events_enabled=true
|
||||
stderr_events_enabled=true
|
||||
@@ -0,0 +1,19 @@
|
||||
[unix_http_server]
|
||||
file=/var/run/supervisor.sock
|
||||
chmod=0700
|
||||
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
childlogdir=/var/log/supervisor
|
||||
user=root
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///var/run/supervisor.sock
|
||||
|
||||
[include]
|
||||
files = /etc/supervisor/conf.d/*.conf
|
||||
Generated
+42
-138
@@ -46,7 +46,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inertiajs/vue3": "2.0",
|
||||
"@inertiajs/vue3": "^3.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
@@ -952,26 +952,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@inertiajs/core": {
|
||||
"version": "2.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.0.17.tgz",
|
||||
"integrity": "sha512-tvYoqiouQSJrP7i7zVq61yyuEjlL96UU4nkkOWtOajXZlubGN4XrgRpnygpDk1KBO8V2yBab3oUZm+aZImwTHg==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.0.3.tgz",
|
||||
"integrity": "sha512-/4sW/cfNpvujjVOZlB5UNypLGNySs7X7V8IMLNSK8+3j1KsUYGS5wpLd9EqAu8wy8RiW7PPra2rPwB6Lx/ACow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.2",
|
||||
"es-toolkit": "^1.34.1",
|
||||
"qs": "^6.9.0"
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"es-toolkit": "^1.33.0",
|
||||
"laravel-precognition": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"axios": "^1.13.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"axios": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inertiajs/vue3": {
|
||||
"version": "2.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.0.17.tgz",
|
||||
"integrity": "sha512-Al0IMHQSj5aTQBLUAkljFEMCw4YRwSiOSKzN8LAbvJpKwvJFgc/wSj3wVVpr/AO9y9mz1w2mtvjnDoOzsntPLw==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-3.0.3.tgz",
|
||||
"integrity": "sha512-bhJN+GS66g1tYH1p6flKkG1N8oaT5J7ZLqBkavN9mHC6bVfoQCUG6sCuA07WTDfo9tDaxU89wsSSAf4mhn3SuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inertiajs/core": "2.0.17",
|
||||
"es-toolkit": "^1.33.0"
|
||||
"@inertiajs/core": "3.0.3",
|
||||
"es-toolkit": "^1.33.0",
|
||||
"laravel-precognition": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
@@ -3804,9 +3813,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
||||
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -4372,6 +4381,24 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-precognition": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz",
|
||||
"integrity": "sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-toolkit": "^1.32.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"axios": "^1.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"axios": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-vite-plugin": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
|
||||
@@ -4875,19 +4902,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-is": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||
@@ -5098,22 +5112,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
||||
@@ -5361,82 +5359,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/skema": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/skema/-/skema-1.0.2.tgz",
|
||||
@@ -6029,24 +5951,6 @@
|
||||
"which": "bin/which"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inertiajs/vue3": "2.0",
|
||||
"@inertiajs/vue3": "^3.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user