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