64 Commits

Author SHA1 Message Date
Simon Pocrnjič 8ffc60aba5 Removed sender bbc 2026-05-21 10:10:34 +02:00
Simon Pocrnjič 7ab890005b fixed the fixed 2026-05-18 12:37:12 +02:00
Simon Pocrnjič b6405764a9 fixed meta param for email template 2026-05-18 12:18:20 +02:00
Simon Pocrnjič 256b311c43 Activity client case archived contract not auto-selected 2026-05-17 23:55:17 +02:00
Simon Pocrnjič 32fe2fbc9b Added "auto_mailer" to mail profile so user can select which profiles are appropriate for auto mails to cliens through activities
Co-authored-by: Copilot <copilot@github.com>
2026-05-11 22:12:57 +02:00
Simon Pocrnjič e3bc5da7e3 Package and individual mail sender, new report, and other changes
Co-authored-by: Copilot <copilot@github.com>
2026-05-11 21:32:30 +02:00
sipo b6bfa17980 Adding activity dialog now auto selects all available contracts use has option to remove them, adding documents on archived contracts is not limited anymore. 2026-05-10 16:30:45 +02:00
Simon Pocrnjič fd81e8ce2d Users custome login redirect 2026-04-21 20:24:58 +02:00
Simon Pocrnjič 054202dc32 Major change update laravel, inertia v2 -> v3, other changes 2026-04-19 13:47:30 +02:00
Simon Pocrnjič 92f54f7103 Changes to phone view, fixed infinity scroll issues with page refresh, updated design a bit 2026-04-18 12:28:15 +02:00
Simon Pocrnjič 8f8c5c5a12 Updated mobile view for field jobs 2026-04-16 23:11:49 +02:00
Simon Pocrnjič 187cb4f127 Fixed when contract is archived all active field jobs for contract are cancaled 2026-04-16 21:52:17 +02:00
Simon Pocrnjič 7881508a7b Fixed dates 2026-04-14 17:41:05 +02:00
Simon Pocrnjič 821985469e Translation user seen text to Slovenian 2026-04-12 21:56:13 +02:00
Simon Pocrnjič a5257df2b7 Added condition options for events to trigger decisions 2026-04-12 21:35:16 +02:00
Simon Pocrnjič 342d9d0700 activity is now added when contract balance is changed 2026-04-02 21:44:15 +02:00
Simon Pocrnjič d54fc9914d reverted copying image change 2026-03-18 21:54:14 +01:00
Simon Pocrnjič f8d1579cb2 Testing image copying 2026-03-18 21:48:39 +01:00
Simon Pocrnjič d80c99c6c0 Fixed dropdown menu where if window small and parts of menu hidden no scroll appeared and user was stuck, also fixed time js which showed +1h, enabled image copying documents 2026-03-18 21:09:30 +01:00
Simon Pocrnjič 9c773be3ec fixed bug in permission edit where checked groups were not checked and remove left and right click image zooming 2026-03-17 21:04:25 +01:00
Simon Pocrnjič 9c6878d1bd Option to add installment to contract/account to increace balance amount same as payment and can be deleted which will reduce balance amount by new amount of the installment deleted, call later added badge to show active call laters 2026-03-11 21:04:20 +01:00
Simon Pocrnjič 5f9d00b575 display call_back_at in activity table 2026-03-09 19:31:03 +01:00
Simon Pocrnjič 2cc765912e Fixed is overdue 2026-03-09 19:19:43 +01:00
Simon Pocrnjič b6e66f0e64 timeZone changed to UTC 2026-03-09 19:14:52 +01:00
Simon Pocrnjič 0aa95fba47 Fixed clock call later 2026-03-09 18:49:08 +01:00
Simon Pocrnjič 0b082549b9 fixed something 2026-03-09 06:30:49 +01:00
Simon Pocrnjič b0d2aa93ab Added call later, option to limit auto mail so for a client person email you can limit which decision activity will be send to that specific email and moved SMS packages from admin panel to default app view 2026-03-08 21:42:39 +01:00
Simon Pocrnjič c16dd51199 Can add activities for archived 2026-02-23 20:08:32 +01:00
Simon Pocrnjič 245caea4dc Remove laravel pagination from contract table / ClientCase view and used replacing it with client pagination 2026-02-14 21:05:01 +01:00
Simon Pocrnjič dda118a005 Fixed some problems 2026-02-05 21:17:16 +01:00
Simon Pocrnjič 8147fedd04 workflow fixed multiselect, combobox width was not limited when selecting desicisions 2026-02-01 19:35:38 +01:00
Simon Pocrnjič b1c531bb70 updated sms package creator, removed result for segments with exeption true, replaced some ui elements 2026-02-01 13:43:18 +01:00
Simon Pocrnjič 9cc1b7072c added download button for orignal import csv file 2026-02-01 09:22:34 +01:00
Simon Pocrnjič 2968bcf3f8 fixed some bugs with dialog and viewing docx works again 2026-01-29 19:14:35 +01:00
Simon Pocrnjič ad0f7a7a01 checkmark for confirmed phone numbers 2026-01-28 21:32:13 +01:00
Simon Pocrnjič 368b0a7cf7 fixed some weird problem with special characters 2026-01-28 20:46:52 +01:00
Simon Pocrnjič aa375ce0da bug fixes, sms, smaller screens elements were overlaping parent containers and updated document viewer 2026-01-28 20:12:26 +01:00
Simon Pocrnjič 340e16c610 Increased post_code length varchar. 2026-01-27 21:07:48 +01:00
Simon Pocrnjič 33b236d881 Small changes 2026-01-27 19:49:09 +01:00
sipo fb7704027b Merge pull request 'production' (#1) from production into master
Reviewed-on: #1
2026-01-27 18:02:43 +00:00
Simon Pocrnjič e5902706f1 Merge remote-tracking branch 'origin/master' into Development 2026-01-27 18:42:27 +01:00
Simon Pocrnjič 229c100cc4 again added fix 2026-01-27 18:10:12 +01:00
Simon Pocrnjič 9a4897bf0c fixed normalizing decimal upsertAccount importer 2026-01-27 18:04:50 +01:00
Simon Pocrnjič d779e4d7a1 Merge branch 'master' into Development 2026-01-21 18:32:28 +01:00
Simon Pocrnjič b2a9350d0f Fixed import check for existing address 2026-01-21 18:31:54 +01:00
Simon Pocrnjič d64a67cf76 Visual changes to profile page 2026-01-19 19:24:41 +01:00
Simon Pocrnjič 068bbdf583 Updated Application icon and notifcation pagination items per page, and updated NotificationsBell 2026-01-18 19:49:48 +01:00
Simon Pocrnjič cc4c07717e Changes 2026-01-18 18:21:41 +01:00
Simon Pocrnjič 28f28be1b8 Merge remote-tracking branch 'origin/master' into Development 2026-01-17 18:51:39 +01:00
Simon Pocrnjič 27bdb942ab Changed Import processor removed getting existing account by reference and just keep contract_id and active true 2026-01-17 17:33:19 +01:00
Simon Pocrnjič ebf9f29200 Merge remote-tracking branch 'origin/master' into Development 2026-01-17 16:06:17 +01:00
Simon Pocrnjič 7eaab16e30 added new permission mass-archive instead if limiting mass archiving to admin users 2026-01-15 21:35:53 +01:00
Simon Pocrnjič 6a2dd860fa Mass archiving added to segment view show 2026-01-15 21:16:26 +01:00
Simon Pocrnjič 091fb07646 Update Person grid view vue and reverted import v2 back to v1 (v2 not production ready) 2026-01-15 20:38:08 +01:00
Simon Pocrnjič 357a254e82 Merge remote-tracking branch 'origin/master' into Development 2026-01-15 17:42:09 +01:00
Simon Pocrnjič aa93c96d31 ignore some .env example files 2026-01-15 17:40:43 +01:00
Simon Pocrnjič ca8754cd94 birthday normalise date 2026-01-14 22:09:04 +01:00
Simon Pocrnjič 8fdc0d6359 Changes to address added fulltext (address,post_code,city), added imployer column to person fix / updated PersonInfoGrid vue component 2026-01-14 21:38:34 +01:00
Simon Pocrnjič df6c3133ec docker setup 2026-01-14 17:33:31 +01:00
Simon Pocrnjič f646b6530a Merge remote-tracking branch 'origin/master' into Development 2026-01-12 20:25:02 +01:00
Simon Pocrnjič 7fc4520dbf Added address to client contracts table 2026-01-12 19:57:04 +01:00
Simon Pocrnjič f66bbbf842 Changes to client contract view 2026-01-12 19:38:23 +01:00
Simon Pocrnjič 4f605451e1 Merge remote-tracking branch 'origin/master' into Development 2026-01-10 20:45:59 +01:00
Simon Pocrnjič dc41862afc Client contracts view added excel export option 2026-01-10 20:36:32 +01:00
203 changed files with 16863 additions and 4135 deletions
+29
View File
@@ -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
+82
View File
@@ -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=
+88
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -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"]
+343
View File
@@ -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)
+159
View File
@@ -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.
+160
View File
@@ -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);
+319 -15
View File
@@ -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.');
}
}
+391 -39
View File
@@ -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 = [];
+91 -12
View File
@@ -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.');
}
}
+16 -11
View File
@@ -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
+4 -2
View File
@@ -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']);
+22 -8
View File
@@ -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;
+18 -9
View File
@@ -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');
}
}
+30 -31
View File
@@ -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 -6
View File
@@ -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,
])
+29 -4
View File
@@ -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'],
];
}
}
+17
View File
@@ -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);
}
}
+296
View File
@@ -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, ',', '.');
}
}
+18
View File
@@ -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([
+5
View File
@@ -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;
}
}
+2 -2
View File
@@ -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!'
+9 -1
View File
@@ -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);
+19
View File
@@ -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');
}
}
+46
View File
@@ -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);
}
}
+15
View File
@@ -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',
];
}
+2
View File
@@ -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',
+6
View File
@@ -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');
}
}
+15
View File
@@ -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);
}
}
+46
View File
@@ -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);
}
}
+19
View File
@@ -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',
];
}
+4 -2
View File
@@ -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',
+2
View File
@@ -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';
+1
View File
@@ -46,6 +46,7 @@ class Person extends Model
'group_id',
'type_id',
'user_id',
'employer'
];
protected $hidden = [
+1
View File
@@ -31,6 +31,7 @@ class User extends Authenticatable
'email',
'password',
'active',
'login_redirect',
];
/**
+3 -1
View File
@@ -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);
}
/**
+41 -3
View File
@@ -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,
]);
+4 -6
View File
@@ -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)) {
+54
View File
@@ -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,
]);
}
}
+5 -1
View File
@@ -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");
-35
View File
@@ -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);
+87 -6
View File
@@ -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();
}
+62 -6
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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');
});
}
};
@@ -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');
});
}
};
+5
View File
@@ -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) {
+2 -1
View File
@@ -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],
],
+262
View File
@@ -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,
]);
}
}
+74
View File
@@ -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
+189
View File
@@ -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
+86
View File
@@ -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;
}
+53
View File
@@ -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;
}
+23
View File
@@ -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
+11
View File
@@ -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
+19
View File
@@ -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
+42 -138
View File
@@ -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
View File
@@ -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