179 Commits

Author SHA1 Message Date
Simon Pocrnjič 8ffc60aba5 Removed sender bbc 2026-05-21 10:10:34 +02:00
Simon Pocrnjič 7ab890005b fixed the fixed 2026-05-18 12:37:12 +02:00
Simon Pocrnjič b6405764a9 fixed meta param for email template 2026-05-18 12:18:20 +02:00
Simon Pocrnjič 256b311c43 Activity client case archived contract not auto-selected 2026-05-17 23:55:17 +02:00
Simon Pocrnjič 32fe2fbc9b Added "auto_mailer" to mail profile so user can select which profiles are appropriate for auto mails to cliens through activities
Co-authored-by: Copilot <copilot@github.com>
2026-05-11 22:12:57 +02:00
Simon Pocrnjič e3bc5da7e3 Package and individual mail sender, new report, and other changes
Co-authored-by: Copilot <copilot@github.com>
2026-05-11 21:32:30 +02:00
sipo b6bfa17980 Adding activity dialog now auto selects all available contracts use has option to remove them, adding documents on archived contracts is not limited anymore. 2026-05-10 16:30:45 +02:00
Simon Pocrnjič fd81e8ce2d Users custome login redirect 2026-04-21 20:24:58 +02:00
Simon Pocrnjič 054202dc32 Major change update laravel, inertia v2 -> v3, other changes 2026-04-19 13:47:30 +02:00
Simon Pocrnjič 92f54f7103 Changes to phone view, fixed infinity scroll issues with page refresh, updated design a bit 2026-04-18 12:28:15 +02:00
Simon Pocrnjič 8f8c5c5a12 Updated mobile view for field jobs 2026-04-16 23:11:49 +02:00
Simon Pocrnjič 187cb4f127 Fixed when contract is archived all active field jobs for contract are cancaled 2026-04-16 21:52:17 +02:00
Simon Pocrnjič 7881508a7b Fixed dates 2026-04-14 17:41:05 +02:00
Simon Pocrnjič 821985469e Translation user seen text to Slovenian 2026-04-12 21:56:13 +02:00
Simon Pocrnjič a5257df2b7 Added condition options for events to trigger decisions 2026-04-12 21:35:16 +02:00
Simon Pocrnjič 342d9d0700 activity is now added when contract balance is changed 2026-04-02 21:44:15 +02:00
Simon Pocrnjič d54fc9914d reverted copying image change 2026-03-18 21:54:14 +01:00
Simon Pocrnjič f8d1579cb2 Testing image copying 2026-03-18 21:48:39 +01:00
Simon Pocrnjič d80c99c6c0 Fixed dropdown menu where if window small and parts of menu hidden no scroll appeared and user was stuck, also fixed time js which showed +1h, enabled image copying documents 2026-03-18 21:09:30 +01:00
Simon Pocrnjič 9c773be3ec fixed bug in permission edit where checked groups were not checked and remove left and right click image zooming 2026-03-17 21:04:25 +01:00
Simon Pocrnjič 9c6878d1bd Option to add installment to contract/account to increace balance amount same as payment and can be deleted which will reduce balance amount by new amount of the installment deleted, call later added badge to show active call laters 2026-03-11 21:04:20 +01:00
Simon Pocrnjič 5f9d00b575 display call_back_at in activity table 2026-03-09 19:31:03 +01:00
Simon Pocrnjič 2cc765912e Fixed is overdue 2026-03-09 19:19:43 +01:00
Simon Pocrnjič b6e66f0e64 timeZone changed to UTC 2026-03-09 19:14:52 +01:00
Simon Pocrnjič 0aa95fba47 Fixed clock call later 2026-03-09 18:49:08 +01:00
Simon Pocrnjič 0b082549b9 fixed something 2026-03-09 06:30:49 +01:00
Simon Pocrnjič b0d2aa93ab Added call later, option to limit auto mail so for a client person email you can limit which decision activity will be send to that specific email and moved SMS packages from admin panel to default app view 2026-03-08 21:42:39 +01:00
Simon Pocrnjič c16dd51199 Can add activities for archived 2026-02-23 20:08:32 +01:00
Simon Pocrnjič 245caea4dc Remove laravel pagination from contract table / ClientCase view and used replacing it with client pagination 2026-02-14 21:05:01 +01:00
Simon Pocrnjič dda118a005 Fixed some problems 2026-02-05 21:17:16 +01:00
Simon Pocrnjič 8147fedd04 workflow fixed multiselect, combobox width was not limited when selecting desicisions 2026-02-01 19:35:38 +01:00
Simon Pocrnjič b1c531bb70 updated sms package creator, removed result for segments with exeption true, replaced some ui elements 2026-02-01 13:43:18 +01:00
Simon Pocrnjič 9cc1b7072c added download button for orignal import csv file 2026-02-01 09:22:34 +01:00
Simon Pocrnjič 2968bcf3f8 fixed some bugs with dialog and viewing docx works again 2026-01-29 19:14:35 +01:00
Simon Pocrnjič ad0f7a7a01 checkmark for confirmed phone numbers 2026-01-28 21:32:13 +01:00
Simon Pocrnjič 368b0a7cf7 fixed some weird problem with special characters 2026-01-28 20:46:52 +01:00
Simon Pocrnjič aa375ce0da bug fixes, sms, smaller screens elements were overlaping parent containers and updated document viewer 2026-01-28 20:12:26 +01:00
Simon Pocrnjič 340e16c610 Increased post_code length varchar. 2026-01-27 21:07:48 +01:00
Simon Pocrnjič 33b236d881 Small changes 2026-01-27 19:49:09 +01:00
sipo fb7704027b Merge pull request 'production' (#1) from production into master
Reviewed-on: #1
2026-01-27 18:02:43 +00:00
Simon Pocrnjič e5902706f1 Merge remote-tracking branch 'origin/master' into Development 2026-01-27 18:42:27 +01:00
Simon Pocrnjič 229c100cc4 again added fix 2026-01-27 18:10:12 +01:00
Simon Pocrnjič 9a4897bf0c fixed normalizing decimal upsertAccount importer 2026-01-27 18:04:50 +01:00
Simon Pocrnjič d779e4d7a1 Merge branch 'master' into Development 2026-01-21 18:32:28 +01:00
Simon Pocrnjič b2a9350d0f Fixed import check for existing address 2026-01-21 18:31:54 +01:00
Simon Pocrnjič d64a67cf76 Visual changes to profile page 2026-01-19 19:24:41 +01:00
Simon Pocrnjič 068bbdf583 Updated Application icon and notifcation pagination items per page, and updated NotificationsBell 2026-01-18 19:49:48 +01:00
Simon Pocrnjič cc4c07717e Changes 2026-01-18 18:21:41 +01:00
Simon Pocrnjič 28f28be1b8 Merge remote-tracking branch 'origin/master' into Development 2026-01-17 18:51:39 +01:00
Simon Pocrnjič 27bdb942ab Changed Import processor removed getting existing account by reference and just keep contract_id and active true 2026-01-17 17:33:19 +01:00
Simon Pocrnjič ebf9f29200 Merge remote-tracking branch 'origin/master' into Development 2026-01-17 16:06:17 +01:00
Simon Pocrnjič 7eaab16e30 added new permission mass-archive instead if limiting mass archiving to admin users 2026-01-15 21:35:53 +01:00
Simon Pocrnjič 6a2dd860fa Mass archiving added to segment view show 2026-01-15 21:16:26 +01:00
Simon Pocrnjič 091fb07646 Update Person grid view vue and reverted import v2 back to v1 (v2 not production ready) 2026-01-15 20:38:08 +01:00
Simon Pocrnjič 357a254e82 Merge remote-tracking branch 'origin/master' into Development 2026-01-15 17:42:09 +01:00
Simon Pocrnjič aa93c96d31 ignore some .env example files 2026-01-15 17:40:43 +01:00
Simon Pocrnjič ca8754cd94 birthday normalise date 2026-01-14 22:09:04 +01:00
Simon Pocrnjič 8fdc0d6359 Changes to address added fulltext (address,post_code,city), added imployer column to person fix / updated PersonInfoGrid vue component 2026-01-14 21:38:34 +01:00
Simon Pocrnjič df6c3133ec docker setup 2026-01-14 17:33:31 +01:00
Simon Pocrnjič f646b6530a Merge remote-tracking branch 'origin/master' into Development 2026-01-12 20:25:02 +01:00
Simon Pocrnjič 7fc4520dbf Added address to client contracts table 2026-01-12 19:57:04 +01:00
Simon Pocrnjič f66bbbf842 Changes to client contract view 2026-01-12 19:38:23 +01:00
Simon Pocrnjič 4f605451e1 Merge remote-tracking branch 'origin/master' into Development 2026-01-10 20:45:59 +01:00
Simon Pocrnjič dc41862afc Client contracts view added excel export option 2026-01-10 20:36:32 +01:00
Simon Pocrnjič c4d2f6e473 Changes 2026-01-10 20:11:20 +01:00
Simon Pocrnjič 711438d79f Merge remote-tracking branch 'origin/master' into Development 2026-01-06 19:49:28 +01:00
Simon Pocrnjič fb6474ab88 changes to old import check if for account if balance_amount and initial_amount are empty or null by default value is set to 0 2026-01-06 19:39:18 +01:00
Simon Pocrnjič 6871fe8796 Phone view updated with shadcn-vue components 2026-01-06 18:45:48 +01:00
Simon Pocrnjič 137e0b45ad Merge remote-tracking branch 'origin/master' into Development 2026-01-05 20:45:18 +01:00
Simon Pocrnjič 2ad24216ae Added Client case person address to segment tables and exports 2026-01-05 19:41:39 +01:00
Simon Pocrnjič c4d9ecb39e Admin panel updated with shadcn-vue components 2026-01-05 18:27:35 +01:00
Simon Pocrnjič 70a5d015e0 Merge remote-tracking branch 'origin/master' into Development 2026-01-02 15:04:58 +01:00
Simon Pocrnjič 8031501d25 Changes to import where on reactivation if start_date not set import mapping it should fill field with current date, same for end_date but it sets it to null 2026-01-02 14:49:05 +01:00
Simon Pocrnjič 703b52ff59 New report system and views 2026-01-02 12:32:20 +01:00
Simon Pocrnjič 9fc5b54b8a again and again fixed import export template 2025-12-28 14:58:38 +01:00
Simon Pocrnjič 082a637719 another fix for export import templates 2025-12-28 14:46:18 +01:00
Simon Pocrnjič b9f66cbfbe Import export templates 2025-12-28 14:29:07 +01:00
Simon Pocrnjič 36b63a180d fixed import 2025-12-28 13:55:09 +01:00
Simon Pocrnjič 84b75143df Changes to field job view and controller 2025-12-28 12:15:37 +01:00
Simon Pocrnjič dea7432deb changes 2025-12-26 22:39:58 +01:00
Simon Pocrnjič f8623a6071 Changes to import / template pages frontend updated design 2025-12-22 20:52:45 +01:00
Simon Pocrnjič ee641586c3 Merge remote-tracking branch 'origin/master' into Development 2025-12-21 21:22:36 +01:00
Simon Pocrnjič adc2a64687 Fixed some field job problem where field operator could still see archived contracts 2025-12-21 21:00:49 +01:00
Simon Pocrnjič 11206fb4f7 UTF8 fixed 2025-12-18 20:48:11 +01:00
Simon Pocrnjič 39a597f6eb added #N/A check 2025-12-18 20:22:14 +01:00
Simon Pocrnjič 5d4498ac5a fixed address import 2025-12-18 19:40:27 +01:00
Simon Pocrnjič 622f53e401 removed something 2025-12-18 18:25:15 +01:00
Simon Pocrnjič 96473fd60b dwdwf 2025-12-17 22:21:36 +01:00
Simon Pocrnjič 5ddca35389 test 2025-12-17 22:17:00 +01:00
Simon Pocrnjič 94ad0c0772 test 2025-12-17 22:14:43 +01:00
Simon Pocrnjič 2140181a76 sdwsd 2025-12-17 21:50:48 +01:00
Simon Pocrnjič 06fa443b3e trimming additional spaces example TEST 2 now to TEST 2 2025-12-17 21:22:17 +01:00
Simon Pocrnjič 6c45063e47 fixed naslove 2025-12-17 21:12:53 +01:00
Simon Pocrnjič b8c9b51f29 test 2025-12-17 20:51:31 +01:00
Simon Pocrnjič a4db37adfa Fix error reporting 2025-12-17 20:49:11 +01:00
Simon Pocrnjič 76f76f73b4 error handling importer 2025-12-17 20:45:02 +01:00
Simon Pocrnjič 85922bdac0 Merge remote-tracking branch 'origin/master' into Development 2025-12-16 20:18:37 +01:00
Simon Pocrnjič d69f4dd6f6 On template creation for entity activities action and decision or not required anymore 2025-12-16 20:18:23 +01:00
Simon Pocrnjič 3291e9b439 Merge remote-tracking branch 'origin/master' into Development 2025-12-16 19:36:13 +01:00
Simon Pocrnjič a596177a68 changes to import added activity entity 2025-12-16 19:35:51 +01:00
Simon Pocrnjič c7164be323 Merge branch 'master' into Development 2025-12-14 20:58:17 +01:00
Simon Pocrnjič 80948d2944 update case index page segment index and show page 2025-12-14 20:57:39 +01:00
Simon Pocrnjič aa40ebed5c Again contract format problem in excel export fixed now! 2025-12-10 21:36:17 +01:00
Simon Pocrnjič 79de54eef0 Contract reference text format inside exported excel 2025-12-10 21:15:53 +01:00
Simon Pocrnjič 53941c054e add maatwebsite/excel to composer.json as required package 2025-12-10 21:05:22 +01:00
Simon Pocrnjič 1a7d2793b0 removed csrf from blade 2025-12-10 20:52:31 +01:00
Simon Pocrnjič a6ec92ec6b Merge branch 'master' into Development 2025-12-10 20:42:08 +01:00
Simon Pocrnjič fa54cf48f3 Segment view contract export option 2025-12-10 20:41:10 +01:00
Simon Pocrnjič e10990411e fixd migration 2025-12-07 17:06:47 +01:00
Simon Pocrnjič 37205e0dea Update CommandDialog fixed bug 2025-12-07 16:31:30 +01:00
Simon Pocrnjič d2287ef963 Changed SMS package so it allows separate SMS for each person contract. 2025-12-07 09:45:19 +01:00
Simon Pocrnjič 70dc0b893f Changes to dev branch 2025-12-07 09:20:04 +01:00
Simon Pocrnjič f5530edcea Merge branch 'master' into Development 2025-12-02 21:17:14 +01:00
Simon Pocrnjič fb7160eb33 fixed search 2025-12-02 20:24:57 +01:00
Simon Pocrnjič c4a78b4632 Test commit to new origin 2025-12-01 19:30:53 +01:00
Simon Pocrnjič c1ac92efbf Dashboard final version, TODO: update main sidebar menu 2025-11-23 21:33:01 +01:00
Simon Pocrnjič c3de189e9d Merge branch 'master' into Development 2025-11-20 18:53:49 +01:00
Simon Pocrnjič 3b284fa4bd Changes to UI and other stuff 2025-11-20 18:11:43 +01:00
Simon Pocrnjič 44f9f8f9fa Option to edit contract metadata 2025-11-20 18:04:33 +01:00
Simon Pocrnjič edbdb64102 Updated client contract table and notification table, multiselect 2025-11-18 21:46:22 +01:00
Simon Pocrnjič 8125b4d321 paketno sms filter zadnja obljuba 2025-11-10 19:07:54 +01:00
Simon Pocrnjič 46feba2df7 meta data za SMS 2025-11-06 22:30:17 +01:00
Simon Pocrnjič 1395b72ae8 changes to sms packages and option to create user 2025-11-06 21:54:07 +01:00
Simon Pocrnjič ad8e0d5cee notifications fixed 2025-11-04 20:12:38 +01:00
Simon Pocrnjič b7fa2d261b changes UI 2025-11-04 18:53:23 +01:00
Simon Pocrnjič fd9f26d82a Changes to post|put|patch|delete 2025-11-02 21:46:02 +01:00
Simon Pocrnjič 63e0958b66 Dev branch 2025-11-02 12:31:01 +01:00
Simon Pocrnjič 5f879c9436 Field job changed permissions and other things 2025-10-31 13:21:54 +01:00
Simon Pocrnjič 0d9c8c8b30 Other permissions changed 2025-10-31 12:26:58 +01:00
Simon Pocrnjič 07b1deda21 Added frontend person edit permission client@show 2025-10-31 11:04:29 +01:00
Simon Pocrnjič ed4f67effb Add more permissions 2025-10-31 10:16:38 +01:00
Simon Pocrnjič 7d4d18143d Notifications changes so they are not filtered by user 2025-10-30 20:03:40 +01:00
Simon Pocrnjič bdde610178 fixed import where it did not reactivate contract that was archived 2025-10-30 19:49:31 +01:00
Simon Pocrnjič cb7851f91c datatype date fixed 2025-10-30 18:21:47 +01:00
Simon Pocrnjič 20d4907fc5 SMS limiter 2025-10-27 19:00:00 +01:00
Simon Pocrnjič 369af34ad4 Package system sms 2025-10-26 12:57:09 +01:00
Simon Pocrnjič 266af6595e Fixed some things 2025-10-24 22:06:57 +02:00
Simon Pocrnjič 930ac83604 SMS service 2025-10-24 21:39:10 +02:00
Simon Pocrnjič 3a2eed7dda Notifications change 2025-10-23 00:10:38 +02:00
Simon Pocrnjič 67ebe4b225 Big changes added events for decisions 2025-10-22 23:20:04 +02:00
Simon Pocrnjič 872b76b012 changes 2025-10-20 19:39:26 +02:00
Simon Pocrnjič 90bbf1942c activity note added deticated button for copy 2025-10-19 10:16:15 +02:00
Simon Pocrnjič 424151497d Note activity fixed Dropdown box 2025-10-19 10:09:48 +02:00
Simon Pocrnjič abf1676292 fixed segment client case show for archive 2025-10-19 09:57:57 +02:00
Simon Pocrnjič ea00852528 some changes 2025-10-19 09:35:30 +02:00
Simon Pocrnjič 322bd66502 Updated settings for actions and decitions 2025-10-18 23:12:48 +02:00
Simon Pocrnjič 8f2e5e282c Changes to UI 2025-10-18 22:56:51 +02:00
Simon Pocrnjič bf09164dbe Log what happens and why attachement is not in mail 2025-10-18 20:29:20 +02:00
Simon Pocrnjič f54f198879 Fixed attachemnts not working on production 2025-10-18 19:53:23 +02:00
Simon Pocrnjič 3b1a24287a Changes to documents able to edit them now, also support for auto mail attechemnts 2025-10-18 19:04:10 +02:00
Simon Pocrnjič 761799bdbe Some changes to UI and emails are now searchable 2025-10-17 21:22:30 +02:00
Simon Pocrnjič 04f31e62aa field job added option to add multiple contracts to user at once 2025-10-16 21:28:40 +02:00
Simon Pocrnjič e782bcca7c import fix for update so it does not insert person and client case 2025-10-16 20:11:45 +02:00
Simon Pocrnjič ed62311ba4 emailer update fixed so it can now send to multiple recipients 2025-10-15 23:46:44 +02:00
Simon Pocrnjič ddfc79ffe8 front end updates 2025-10-14 21:00:26 +02:00
Simon Pocrnjič 79b3e20b02 Changes to import and notifications 2025-10-13 21:14:10 +02:00
Simon Pocrnjič 0bbed64542 importent update 2025-10-13 17:22:02 +02:00
Simon Pocrnjič ec6456cf23 updated document 2025-10-12 19:07:41 +02:00
sipo 23f2011e33 Document gen fixed 2025-10-12 17:52:17 +02:00
Simon Pocrnjič e0303ece74 documents 2025-10-12 12:24:17 +02:00
Simon Pocrnjič 3ab1c05fcc Decision now support auto mailing 2025-10-12 00:20:03 +02:00
Simon Pocrnjič 1b615163be email support 2025-10-11 17:20:05 +02:00
Simon Pocrnjič 7c7defb6c5 sklic not showing right contract meta 2025-10-10 19:32:45 +02:00
Simon Pocrnjič afaefa8a9d chane to import 2025-10-10 19:00:29 +02:00
Simon Pocrnjič 0598261cdc Importer update add support for meta data and multiple inserts for some entities like addresses and phones, updated other things 2025-10-09 22:28:48 +02:00
Simon Pocrnjič c8029c9eb0 Teren dodeljen danes statistika 2025-10-09 01:05:17 +02:00
Simon Pocrnjič 6108028942 Arhived fixed 2025-10-09 00:41:39 +02:00
Simon Pocrnjič 932bbdc294 client emails were now shown 2025-10-09 00:17:46 +02:00
Simon Pocrnjič 078b08cbc5 mails clients 2025-10-09 00:09:23 +02:00
Simon Pocrnjič 86898eac1a Emergency button for missing persons 2025-10-09 00:01:15 +02:00
Simon Pocrnjič c177264b0b document bug where documents from other contracts are shown under client case with not contracts 2025-10-08 23:11:42 +02:00
Simon Pocrnjič 1b96b0d821 dd 2025-10-08 22:42:27 +02:00
Simon Pocrnjič 39dd3d4d8f changes 2025-10-08 22:36:54 +02:00
sipo f40c3d0f2e changes 2025-10-08 18:26:47 +02:00
sipo ee1af56d03 Dokument popravljeni 2025-10-08 16:31:23 +02:00
Simon Pocrnjič b9ca8244ef Mail support testing faze 2025-10-07 21:57:10 +02:00
Simon Pocrnjič 175111bed4 Fixes to client case show 2025-10-07 19:47:54 +02:00
Simon Pocrnjič f976b4d6ef change test user pass 2025-10-06 22:19:52 +02:00
Simon Pocrnjič 9e47b399ed fix to roles 2025-10-06 22:13:37 +02:00
872 changed files with 94016 additions and 17015 deletions
+29
View File
@@ -0,0 +1,29 @@
.git
.gitignore
.github
.gitattributes
.env
.env.*
!.env.production.example
node_modules
npm-debug.log
vendor
storage/app/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
storage/logs/*
bootstrap/cache/*
public/storage
public/hot
*.md
!README.md
tests
.phpunit.result.cache
phpunit.xml
docker-compose*.yml
.editorconfig
.styleci.yml
*.log
.DS_Store
Thumbs.db
+82
View File
@@ -0,0 +1,82 @@
APP_NAME="Teren App"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost:8080
APP_LOCALE=sl
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=sl_SI
APP_MAINTENANCE_DRIVER=file
APP_MAINTENANCE_STORE=database
BCRYPT_ROUNDS=12
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
# Database
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=teren_app
DB_USERNAME=teren_user
DB_PASSWORD=local_password
# Redis
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PORT=6379
# Queue
QUEUE_CONNECTION=redis
# Session
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=
SESSION_SECURE_COOKIE=false
SESSION_SAME_SITE=lax
# Cache
CACHE_STORE=redis
# Mail (Mailpit for local testing)
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
SCOUT_PREFIX=
SCOUT_QUEUE=true
# Sanctum
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,localhost:8080,127.0.0.1:8080
# Logging
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# Vite
VITE_APP_NAME="${APP_NAME}"
VITE_DEV_SERVER_KEY=
VITE_DEV_SERVER_CERT=
# LibreOffice for document previews (Docker container path)
LIBREOFFICE_BIN=/usr/bin/soffice
# Storage configuration for generated previews
FILES_PREVIEW_DISK=public
FILES_PREVIEW_BASE=previews/casesNEL=null
LOG_LEVEL=debug
# Vite
VITE_DEV_SERVER_KEY=
VITE_DEV_SERVER_CERT=
+88
View File
@@ -0,0 +1,88 @@
APP_NAME="Teren App"
APP_ENV=production
APP_KEY= # Generate with: php artisan key:generate
APP_DEBUG=false
APP_TIMEZONE=UTC
APP_URL=https://example.com # Your domain
APP_LOCALE=sl
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=sl_SI
APP_MAINTENANCE_DRIVER=file
APP_MAINTENANCE_STORE=database
BCRYPT_ROUNDS=12
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
# Database
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=teren_app
DB_USERNAME=teren_user
DB_PASSWORD= # Generate a strong password
# Redis
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PORT=6379
# Queue
QUEUE_CONNECTION=redis
# Session
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
# Cache
CACHE_STORE=redis
# pgAdmin
PGADMIN_EMAIL=admin@example.com
PGADMIN_PASSWORD= # Generate a strong password
# WireGuard VPN (REQUIRED - app is VPN-only)
WG_SERVERURL=vpn.example.com # Your VPS public IP or domain
WG_UI_PASSWORD= # Generate a strong password for WireGuard dashboard
# Mail (configure as needed)
MAIL_MAILER=log
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PA
SCOUT_DRIVER=database
SCOUT_PREFIX=
SCOUT_QUEUE=true
# Sanctum
SANCTUM_STATEFUL_DOMAINS=example.com,www.example.com,10.13.13.1
# Logging
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=error
# Vite
VITE_APP_NAME="${APP_NAME}"
# LibreOffice for document previews (Docker container path)
LIBREOFFICE_BIN=/usr/bin/soffice
# Storage configuration for generated previews
FILES_PREVIEW_DISK=public
FILES_PREVIEW_BASE=previews/cases
# Logging
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=error
+32 -4
View File
@@ -22,7 +22,7 @@ ## Foundational Context
- pestphp/pest (PEST) - v3 - pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11 - phpunit/phpunit (PHPUNIT) - v11
- @inertiajs/vue3 (INERTIA) - v2 - @inertiajs/vue3 (INERTIA) - v2
- tailwindcss (TAILWINDCSS) - v3 - tailwindcss (TAILWINDCSS) - v4
- vue (VUE) - v3 - vue (VUE) - v3
@@ -359,11 +359,39 @@ ### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. - If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v3 rules === === tailwindcss/v4 rules ===
## Tailwind 3 ## Tailwind 4
- Always use Tailwind CSS v3 - verify you're using only classes supported by this version. - Always use Tailwind CSS v4 - do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
=== tests rules === === tests rules ===
+20
View File
@@ -19,3 +19,23 @@ yarn-error.log
/.idea /.idea
/.vscode /.vscode
/.zed /.zed
/shadcn-vue
# Development/Testing Scripts
check-*.php
test-*.php
fix-*.php
clean-*.php
mark-*.php
# Development Documentation
IMPORT_*.md
V2_*.md
REPORTS_*.md
DEDUPLICATION_*.md
# Docker Local Testing
docker-compose.local.yaml
docker-compose.override.yaml
.env.local
.env.docker
+654
View File
@@ -0,0 +1,654 @@
# V2 Deduplication Implementation Plan
## Problem Statement
Currently, ImportServiceV2 allows duplicate Person records and related entities when:
1. A ClientCase with the same `client_ref` already exists in the database
2. A Contract with the same `reference` already exists for the client
3. Person data is present in the import row
This causes data duplication because V2 doesn't check for existing entities before creating Person and related entities (addresses, phones, emails, activities).
## V1 Deduplication Strategy (Analysis)
### V1 Person Resolution Order (Lines 913-1015)
V1 follows this hierarchical lookup before creating a new Person:
1. **Contract Reference Lookup** (Lines 913-922)
- If contract.reference exists → Find existing Contract → Get ClientCase → Get Person
- Prevents creating new Person when Contract already exists
2. **Account Result Derivation** (Lines 924-936)
- If Account processing resolved/created a Contract → Get ClientCase → Get Person
3. **ClientCase.client_ref Lookup** (Lines 937-945)
- If client_ref exists → Find ClientCase by (client_id, client_ref) → Get Person
- Prevents creating new Person when ClientCase already exists
4. **Contact Values Lookup** (Lines 949-964)
- Check Email.value → Get Person
- Check PersonPhone.nu → Get Person
- Check PersonAddress.address → Get Person
5. **Person Identifiers Lookup** (Lines 1005-1007)
- Check tax_number, ssn, etc. via `findPersonIdByIdentifiers()`
6. **Create New Person** (Lines 1009-1011)
- Only if all above fail
### V1 Contract Deduplication (Lines 2158-2196)
**Early Contract Lookup** (Lines 2168-2180):
```php
// Try to find existing contract EARLY by (client_id, reference)
// across all cases to prevent duplicates
$existing = Contract::query()->withTrashed()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->select('contracts.*')
->first();
```
**ClientCase Reuse Logic** (Lines 2214-2228):
```php
// If we have a client and client_ref, try to reuse existing case
// to avoid creating extra persons
if ($clientId && $clientRef) {
$cc = ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->first();
if ($cc) {
// Reuse this case
$clientCaseId = $cc->id;
// If case has no person yet, set it
if (!$cc->person_id) {
// Find or create person and attach
}
}
}
```
### Key V1 Design Principles
**Resolution before Creation** - Always check for existing entities first
**Chain Derivation** - Contract → ClientCase → Person (reuse existing chain)
**Contact Deduplication** - Match by email/phone/address before creating
**Client-Scoped Lookups** - All queries scoped to import.client_id
**Minimal Person Creation** - Only create Person as last resort
## V2 Current Architecture Issues
### Problem Areas
1. **PersonHandler** (`app/Services/Import/Handlers/PersonHandler.php`)
- Currently only deduplicates by tax_number/ssn (Lines 38-58)
- Doesn't check if Person exists via Contract/ClientCase
- Processes independently without context awareness
2. **ClientCaseHandler** (`app/Services/Import/Handlers/ClientCaseHandler.php`)
- Correctly resolves by client_ref (Lines 16-27)
- But doesn't prevent PersonHandler from running afterwards
3. **ContractHandler** (`app/Services/Import/Handlers/ContractHandler.php`)
- Missing early resolution logic
- Doesn't derive Person from existing Contract chain
4. **Processing Order Issue**
- Current priority: Person(100) → ClientCase(95) → Contract(90)
- Person runs BEFORE we know if ClientCase/Contract exists
- Should be reversed: Contract → ClientCase → Person
## V2 Deduplication Plan
### Phase 1: Reverse Processing Order ✅
**Change entity priorities in database seeder:**
```php
// NEW ORDER (descending priority)
Contract: 100
ClientCase: 95
Person: 90
Email: 80
Address: 70
Phone: 60
Account: 50
Payment: 40
Activity: 30
```
**Rationale:** Process high-level entities first (Contract, ClientCase) so we can derive Person from existing chains.
### Phase 2: Early Resolution Service 🔧
**Create:** `app/Services/Import/EntityResolutionService.php`
This service will be called BEFORE handlers process entities:
```php
class EntityResolutionService
{
/**
* Resolve Person ID from import context (existing entities).
* Returns Person ID if found, null otherwise.
*/
public function resolvePersonFromContext(
Import $import,
array $mapped,
array $context
): ?int {
// 1. Check if Contract already processed
if ($contract = $context['contract']['entity'] ?? null) {
$personId = $this->getPersonFromContract($contract);
if ($personId) return $personId;
}
// 2. Check if ClientCase already processed
if ($clientCase = $context['client_case']['entity'] ?? null) {
if ($clientCase->person_id) {
return $clientCase->person_id;
}
}
// 3. Check for existing Contract by reference
if ($contractRef = $mapped['contract']['reference'] ?? null) {
$personId = $this->getPersonFromContractReference(
$import->client_id,
$contractRef
);
if ($personId) return $personId;
}
// 4. Check for existing ClientCase by client_ref
if ($clientRef = $mapped['client_case']['client_ref'] ?? null) {
$personId = $this->getPersonFromClientRef(
$import->client_id,
$clientRef
);
if ($personId) return $personId;
}
// 5. Check for existing Person by contact values
$personId = $this->resolvePersonByContacts($mapped);
if ($personId) return $personId;
return null; // No existing Person found
}
/**
* Check if ClientCase exists for this client_ref.
*/
public function clientCaseExists(int $clientId, string $clientRef): bool
{
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->exists();
}
/**
* Check if Contract exists for this reference.
*/
public function contractExists(int $clientId, string $reference): bool
{
return Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->exists();
}
private function getPersonFromContract(Contract $contract): ?int
{
if ($contract->client_case_id) {
return ClientCase::where('id', $contract->client_case_id)
->value('person_id');
}
return null;
}
private function getPersonFromContractReference(
?int $clientId,
string $reference
): ?int {
if (!$clientId) return null;
$clientCaseId = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->value('contracts.client_case_id');
if ($clientCaseId) {
return ClientCase::where('id', $clientCaseId)
->value('person_id');
}
return null;
}
private function getPersonFromClientRef(
?int $clientId,
string $clientRef
): ?int {
if (!$clientId) return null;
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->value('person_id');
}
private function resolvePersonByContacts(array $mapped): ?int
{
// Check email
if ($email = $mapped['email']['value'] ?? $mapped['emails'][0]['value'] ?? null) {
$personId = Email::where('value', trim($email))->value('person_id');
if ($personId) return $personId;
}
// Check phone
if ($phone = $mapped['phone']['nu'] ?? $mapped['person_phones'][0]['nu'] ?? null) {
$personId = PersonPhone::where('nu', trim($phone))->value('person_id');
if ($personId) return $personId;
}
// Check address
if ($address = $mapped['address']['address'] ?? $mapped['person_addresses'][0]['address'] ?? null) {
$personId = PersonAddress::where('address', trim($address))->value('person_id');
if ($personId) return $personId;
}
return null;
}
}
```
### Phase 3: Update PersonHandler 🔧
**Modify:** `app/Services/Import/Handlers/PersonHandler.php`
Add resolution service check before creating:
```php
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// FIRST: Check if Person already resolved from context
$resolutionService = app(EntityResolutionService::class);
$existingPersonId = $resolutionService->resolvePersonFromContext(
$import,
$mapped,
$context
);
if ($existingPersonId) {
$existing = Person::find($existingPersonId);
// Update if configured
$mode = $this->getOption('update_mode', 'update');
if ($mode === 'skip') {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'Person already exists (found via Contract/ClientCase chain)',
];
}
// Update logic...
return [
'action' => 'updated',
'entity' => $existing,
'count' => 1,
];
}
// SECOND: Try existing deduplication (tax_number, ssn)
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Update logic...
}
// THIRD: Check contacts deduplication
$personIdFromContacts = $resolutionService->resolvePersonByContacts($mapped);
if ($personIdFromContacts) {
$existing = Person::find($personIdFromContacts);
// Update logic...
}
// LAST: Create new Person only if all checks failed
$payload = $this->buildPayload($mapped);
$person = Person::create($payload);
return [
'action' => 'inserted',
'entity' => $person,
'count' => 1,
];
}
```
### Phase 4: Update ContractHandler 🔧
**Modify:** `app/Services/Import/Handlers/ContractHandler.php`
Add early Contract lookup and ClientCase reuse:
```php
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
$clientId = $import->client_id;
$reference = $mapped['reference'] ?? null;
if (!$clientId || !$reference) {
return [
'action' => 'invalid',
'errors' => ['Contract requires client_id and reference'],
];
}
// EARLY LOOKUP: Check if Contract exists across all cases
$existing = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->select('contracts.*')
->first();
if ($existing) {
// Contract exists - update or skip
$mode = $this->getOption('update_mode', 'update');
if ($mode === 'skip') {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'Contract already exists',
];
}
// Update logic...
return [
'action' => 'updated',
'entity' => $existing,
'count' => 1,
];
}
// Creating new Contract - resolve/create ClientCase
$clientCaseId = $this->resolveOrCreateClientCase($import, $mapped, $context);
if (!$clientCaseId) {
return [
'action' => 'invalid',
'errors' => ['Unable to resolve client_case_id'],
];
}
// Create Contract
$payload = array_merge($this->buildPayload($mapped), [
'client_case_id' => $clientCaseId,
]);
$contract = Contract::create($payload);
return [
'action' => 'inserted',
'entity' => $contract,
'count' => 1,
];
}
protected function resolveOrCreateClientCase(
Import $import,
array $mapped,
array $context
): ?int {
$clientId = $import->client_id;
$clientRef = $mapped['client_ref'] ??
$context['client_case']['entity']?->client_ref ??
null;
// If ClientCase already processed in this row
if ($clientCaseId = $context['client_case']['entity']?->id ?? null) {
return $clientCaseId;
}
// Try to find existing ClientCase by client_ref
if ($clientRef) {
$existing = ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->first();
if ($existing) {
// REUSE existing ClientCase (and its Person)
return $existing->id;
}
}
// Create new ClientCase (Person should already be processed)
$personId = $context['person']['entity']?->id ?? null;
if (!$personId) {
// Person wasn't in import, create minimal
$personId = Person::create(['type_id' => 1])->id;
}
$clientCase = ClientCase::create([
'client_id' => $clientId,
'person_id' => $personId,
'client_ref' => $clientRef,
]);
return $clientCase->id;
}
```
### Phase 5: Update ClientCaseHandler 🔧
**Modify:** `app/Services/Import/Handlers/ClientCaseHandler.php`
Ensure it uses resolved Person from context:
```php
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
$clientId = $import->client_id ?? null;
$clientRef = $mapped['client_ref'] ?? null;
// Get Person from context (should be processed first now)
$personId = $context['person']['entity']?->id ?? null;
if (!$clientId) {
return [
'action' => 'skipped',
'message' => 'ClientCase requires client_id',
];
}
$existing = $this->resolve($mapped, $context);
if ($existing) {
$mode = $this->getOption('update_mode', 'update');
if ($mode === 'skip') {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'ClientCase already exists (skip mode)',
];
}
$payload = $this->buildPayload($mapped, $existing);
// Update person_id ONLY if provided and different
if ($personId && $existing->person_id !== $personId) {
$payload['person_id'] = $personId;
}
$appliedFields = $this->trackAppliedFields($existing, $payload);
$existing->update($payload);
return [
'action' => 'updated',
'entity' => $existing,
'count' => 1,
];
}
// Create new ClientCase
$payload = $this->buildPayload($mapped);
// Attach Person if resolved
if ($personId) {
$payload['person_id'] = $personId;
}
$payload['client_id'] = $clientId;
$clientCase = ClientCase::create($payload);
return [
'action' => 'inserted',
'entity' => $clientCase,
'count' => 1,
];
}
```
### Phase 6: Integration into ImportServiceV2 🔧
**Modify:** `app/Services/Import/ImportServiceV2.php`
Inject resolution service into processRow:
```php
protected function processRow(Import $import, array $mapped, array $raw, array $context): array
{
$entityResults = [];
$lastEntityType = null;
$lastEntityId = null;
$hasErrors = false;
// NEW: Add resolution service to context
$context['resolution_service'] = app(EntityResolutionService::class);
// Process entities in configured priority order
foreach ($this->entityConfigs as $root => $config) {
// ... existing logic ...
}
// ... rest of method ...
}
```
## Implementation Checklist
### Step 1: Update Database Priority ✅
- [ ] Modify `database/seeders/ImportEntitiesV2Seeder.php`
- [ ] Change priorities: Contract(100), ClientCase(95), Person(90)
- [ ] Run seeder: `php artisan db:seed --class=ImportEntitiesV2Seeder --force`
### Step 2: Create EntityResolutionService 🔧
- [ ] Create `app/Services/Import/EntityResolutionService.php`
- [ ] Implement all resolution methods
- [ ] Add comprehensive PHPDoc
- [ ] Add logging for debugging
### Step 3: Update PersonHandler 🔧
- [ ] Modify `process()` method to check resolution service first
- [ ] Add contact-based deduplication
- [ ] Ensure proper skip/update modes
### Step 4: Update ContractHandler 🔧
- [ ] Add early Contract lookup (client_id + reference)
- [ ] Implement ClientCase reuse logic
- [ ] Prevent duplicate Contract creation
### Step 5: Update ClientCaseHandler 🔧
- [ ] Use Person from context
- [ ] Handle person_id properly on updates
- [ ] Maintain existing deduplication
### Step 6: Integrate into ImportServiceV2 🔧
- [ ] Add resolution service to context
- [ ] Test with existing imports
### Step 7: Testing 🧪
- [ ] Test import with existing client_ref
- [ ] Test import with existing contract reference
- [ ] Test import with existing email/phone
- [ ] Test mixed scenarios
- [ ] Verify no duplicate Persons created
- [ ] Check all related entities linked correctly
## Expected Behavior After Implementation
### Scenario 1: Existing ClientCase by client_ref
```
Import Row: {client_ref: "B387055", name: "John", email: "john@test.com"}
Before V2 Fix:
❌ Creates new Person (duplicate)
❌ Creates new Email (duplicate)
✅ Reuses ClientCase
After V2 Fix:
✅ Finds existing Person via ClientCase
✅ Updates Person if needed
✅ Reuses ClientCase
✅ Reuses/updates Email
```
### Scenario 2: Existing Contract by reference
```
Import Row: {contract.reference: "REF-123", person.name: "Jane"}
Before V2 Fix:
❌ Creates new Person (duplicate)
❌ Contract might be created or updated
❌ New Person not linked to existing ClientCase
After V2 Fix:
✅ Finds existing Contract
✅ Derives Person from Contract → ClientCase chain
✅ Updates Person if needed
✅ No duplicate Person created
```
### Scenario 3: New Import (no existing entities)
```
Import Row: {client_ref: "NEW-001", name: "Bob"}
Behavior:
✅ Creates new Person
✅ Creates new ClientCase
✅ Links correctly
✅ No duplicates
```
## Success Criteria
**No duplicate Persons** when client_ref or contract reference exists
**Proper entity linking** - all entities connected to correct Person
**Backward compatibility** - existing imports still work
**Skip mode respected** - handlers honor skip/update modes
**Contact deduplication** - matches by email/phone/address
**Performance maintained** - no significant slowdown
## Rollback Plan
If issues occur:
1. Revert priority changes in database
2. Disable EntityResolutionService by commenting out context injection
3. Fall back to original handler behavior
4. Investigate and fix issues
5. Re-implement with fixes
## Notes
- This plan maintains V2's modular handler architecture
- Resolution logic is centralized in EntityResolutionService
- Handlers remain independent but context-aware
- Similar to V1 but cleaner separation of concerns
- Can be implemented incrementally (phase by phase)
- Each phase can be tested independently
+1045
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
ARG PHP_VERSION=8.4
FROM php:${PHP_VERSION}-fpm-alpine
# Set working directory
WORKDIR /var/www
# Install system dependencies
RUN apk add --no-cache \
git \
curl \
zip \
unzip \
supervisor \
nginx \
postgresql-dev \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
libwebp-dev \
oniguruma-dev \
libxml2-dev \
linux-headers \
${PHPIZE_DEPS}
# Configure and install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install -j$(nproc) \
pdo_pgsql \
pgsql \
mbstring \
exif \
pcntl \
bcmath \
gd \
opcache
# Install Redis extension via PECL
RUN pecl install redis \
&& docker-php-ext-enable redis
# Install LibreOffice from community repository
RUN apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community \
libreoffice-common \
libreoffice-writer \
libreoffice-calc
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Create system user to run Composer and Artisan Commands
RUN addgroup -g 1000 -S www && \
adduser -u 1000 -S www -G www
# Copy application files (will be overridden by volume mount in local development)
COPY --chown=www:www . /var/www
# Copy supervisor configuration
COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
COPY docker/supervisor/conf.d /etc/supervisor/conf.d
# Set permissions
RUN chown -R www:www /var/www \
&& chmod -R 755 /var/www/storage \
&& chmod -R 755 /var/www/bootstrap/cache
# PHP Configuration for production
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# Copy PHP custom configuration
COPY docker/php/custom.ini $PHP_INI_DIR/conf.d/custom.ini
# Configure PHP-FPM to listen on all interfaces (0.0.0.0) instead of just localhost
# This is needed for nginx running in a separate container to reach PHP-FPM
RUN sed -i 's/listen = 127.0.0.1:9000/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf
# Expose port 9000 for PHP-FPM
EXPOSE 9000
# Create directories for supervisor logs
RUN mkdir -p /var/log/supervisor
# Start supervisor (which will manage both PHP-FPM and Laravel queue workers)
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
+343
View File
@@ -0,0 +1,343 @@
# Local Testing Guide - Windows/Mac/Linux
This guide helps you test the Teren App Docker setup on your local machine without WireGuard VPN.
## Quick Start
### 1. Prerequisites
- Docker Desktop installed and running
- Git
- 8GB RAM recommended
- Ports available: 8080, 5433 (PostgreSQL), 5050, 6379, 9000, 8025, 1025
- **Note:** If you have local PostgreSQL on port 5432, the Docker container uses 5433 instead
### 2. Setup
```bash
# Clone repository (if not already)
git clone YOUR_GITEA_URL
cd Teren-app
# Copy local environment file
cp .env.local.example .env
# Start all services
docker compose -f docker-compose.local.yaml up -d
# Wait for services to start (30 seconds)
timeout 30
# Generate application key
docker compose -f docker-compose.local.yaml exec app php artisan key:generate
# Run migrations
docker compose -f docker-compose.local.yaml exec app php artisan migrate
# Seed database (optional)
docker compose -f docker-compose.local.yaml exec app php artisan db:seed
# Install frontend dependencies (if needed)
npm install
npm run dev
```
### 3. Access Services
| Service | URL | Credentials |
|---------|-----|-------------|
| **Laravel App** | http://localhost:8080 | - |
| **Portainer** | http://localhost:9000 | Set on first visit |
| **pgAdmin** | http://localhost:5050 | admin@local.dev / admin |
| **Mailpit** | http://localhost:8025 | - |
| **PostgreSQL** | localhost:5433 | teren_user / local_password |
| **Redis** | localhost:6379 | - |
**Note:** PostgreSQL uses port 5433 to avoid conflicts with any local PostgreSQL installation.
## Common Commands
### Docker Compose Commands
```bash
# Start all services
docker compose -f docker-compose.local.yaml up -d
# Stop all services
docker compose -f docker-compose.local.yaml down
# View logs
docker compose -f docker-compose.local.yaml logs -f
# View specific service logs
docker compose -f docker-compose.local.yaml logs -f app
# Restart a service
docker compose -f docker-compose.local.yaml restart app
# Rebuild containers
docker compose -f docker-compose.local.yaml up -d --build
# Stop and remove everything (including volumes)
docker compose -f docker-compose.local.yaml down -v
```
### Laravel Commands
```bash
# Run artisan commands
docker compose -f docker-compose.local.yaml exec app php artisan [command]
# Examples:
docker compose -f docker-compose.local.yaml exec app php artisan migrate
docker compose -f docker-compose.local.yaml exec app php artisan db:seed
docker compose -f docker-compose.local.yaml exec app php artisan cache:clear
docker compose -f docker-compose.local.yaml exec app php artisan config:clear
docker compose -f docker-compose.local.yaml exec app php artisan queue:work
# Run tests
docker compose -f docker-compose.local.yaml exec app php artisan test
# Access container shell
docker compose -f docker-compose.local.yaml exec app sh
# Run Composer commands
docker compose -f docker-compose.local.yaml exec app composer install
docker compose -f docker-compose.local.yaml exec app composer update
```
### Database Commands
```bash
# Connect to PostgreSQL (from inside container)
docker compose -f docker-compose.local.yaml exec postgres psql -U teren_user -d teren_app
# Connect from Windows host
psql -h localhost -p 5433 -U teren_user -d teren_app
# Backup database
docker compose -f docker-compose.local.yaml exec postgres pg_dump -U teren_user teren_app > backup.sql
# Restore database
docker compose -f docker-compose.local.yaml exec -T postgres psql -U teren_user teren_app < backup.sql
# Reset database
docker compose -f docker-compose.local.yaml exec app php artisan migrate:fresh --seed
```
## pgAdmin Setup
1. Open http://localhost:5050
2. Login: `admin@local.dev` / `admin`
3. Add Server:
- **General Tab:**
- Name: `Teren Local`
- **Connection Tab:**
- Host: `postgres`
- Port: `5432`
- Database: `teren_app`
- Username: `teren_user`
- Passwo
**External Connection:** To connect from your Windows machine (e.g., DBeaver, pgAdmin desktop), use:
- Host: `localhost`
- Port: `5433` (not 5432)
- Database: `teren_app`
- Username: `teren_user`
- Password: `local_password`rd: `local_password`
4. Click Save
## Mailpit - Email Testing
All emails sent by the application are caught by Mailpit.
- Access: http://localhost:8025
- View all emails in the web interface
- Test email sending:
```bash
docker compose -f docker-compose.local.yaml exec app php artisan tinker
# In tinker:
Mail::raw('Test email', function($msg) {
$msg->to('test@example.com')->subject('Test');
});
```
## Portainer Setup
1. Open http://localhost:9000
2. On first visit, create admin account
3. Select "Docker" environment
4. Click "Connect"
Use Portainer to:
- View and manage containers
- Check logs
- Execute commands in containers
- Monitor resource usage
## Development Workflow
### Frontend Development
The local setup supports live reloading:
```bash
# Run Vite dev server (outside Docker)
npm run dev
# Or inside Docker
docker compose -f docker-compose.local.yaml exec app npm run dev
```
Access: http://localhost:8080
### Code Changes
All code changes are automatically reflected because the source code is mounted as a volume:
```yaml
volumes:
- ./:/var/www # Live code mounting
```
### Queue Workers
Queue workers are running via Supervisor inside the container. To restart:
```bash
# Restart queue workers
docker compose -f docker-compose.local.yaml exec app supervisorctl restart all
# Check status
docker compose -f docker-compose.local.yaml exec app supervisorctl status
# View worker logs
docker compose -f docker-compose.local.yaml exec app tail -f storage/logs/worker.log
```
## Troubleshooting
### Port Already in Use
If you get "port is already allocated" error:
```bash
# Windows - Find process using port
netstat -ano | findstr :8080
# Kill process by PID
taskkill /PID <PID> /F
# Or change port in docker-compose.local.yaml
ports:
- "8081:80" # Change 8080 to 8081
```
### Container Won't Start
```bash
# Check logs
docker compose -f docker-compose.local.yaml logs app
# Rebuild containers
docker compose -f docker-compose.local.yaml down
docker compose -f docker-compose.local.yaml up -d --build
```
### Permission Errors (Linux/Mac)
```bash
# Fix storage permissions
docker compose -f docker-compose.local.yaml exec app chown -R www:www /var/www/storage
docker compose -f docker-compose.local.yaml exec app chmod -R 775 /var/www/storage
```
### Database Connection Failed
```bash
# Check if PostgreSQL is running
docker compose -f docker-compose.local.yaml ps postgres
# Check logs
docker compose -f docker-compose.local.yaml logs postgres
# Restart PostgreSQL
docker compose -f docker-compose.local.yaml restart postgres
```
### Clear All Data and Start Fresh
```bash
# Stop and remove everything
docker compose -f docker-compose.local.yaml down -v
# Remove images
docker compose -f docker-compose.local.yaml down --rmi all
# Start fresh
docker compose -f docker-compose.local.yaml up -d --build
# Re-initialize
docker compose -f docker-compose.local.yaml exec app php artisan key:generate
docker compose -f docker-compose.local.yaml exec app php artisan migrate:fresh --seed
```
## Performance Tips
### Windows Performance
If using WSL2 (recommended):
1. Clone repo inside WSL2 filesystem, not Windows filesystem
2. Use WSL2 terminal for commands
3. Enable WSL2 integration in Docker Desktop settings
### Mac Performance
1. Enable VirtioFS in Docker Desktop settings
2. Disable file watching if not needed
3. Use Docker volumes for vendor directories:
```yaml
volumes:
- ./:/var/www
- /var/www/vendor # Anonymous volume for vendor
- /var/www/node_modules # Anonymous volume for node_modules
```
## Testing Production-Like Setup
To test the production VPN setup locally (advanced):
1. Enable WireGuard in `docker-compose.yaml.example`
2. Change all `10.13.13.1` bindings to `127.0.0.1`
3. Test SSL with self-signed certificates
## Differences from Production
| Feature | Local | Production |
|---------|-------|------------|
| **VPN** | No VPN | WireGuard required |
| **Port** | :8080 | :80/:443 |
| **SSL** | No SSL | Let's Encrypt |
| **Debug** | Enabled | Disabled |
| **Emails** | Mailpit | Real SMTP |
| **Logs** | Debug level | Error level |
| **Code** | Live mount | Built into image |
## Next Steps
After testing locally:
1. Review `docker-compose.yaml.example` for production
2. Follow `DEPLOYMENT_GUIDE.md` for VPS setup
3. Configure WireGuard VPN
4. Deploy to production
## Useful Resources
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [Laravel Docker Documentation](https://laravel.com/docs/deployment)
- [PostgreSQL Docker](https://hub.docker.com/_/postgres)
- [Mailpit Documentation](https://github.com/axllent/mailpit)
+159
View File
@@ -0,0 +1,159 @@
# Quick Start: VPN-Only Access Setup
⚠️ **IMPORTANT:** This application is configured for VPN-ONLY access. It will NOT be publicly accessible.
## Quick Setup Steps
### 1. Install Docker (on VPS)
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
```
### 2. Clone & Configure
```bash
git clone YOUR_GITEA_REPO/Teren-app.git
cd Teren-app
cp docker-compose.yaml.example docker-compose.yaml
cp .env.production.example .env
```
### 3. Edit Configuration
```bash
vim .env
```
**Required changes:**
- `WG_SERVERURL` = Your VPS public IP (e.g., `123.45.67.89`)
- `WG_UI_PASSWORD` = Strong password for WireGuard dashboard
- `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` = Database credentials
- `PGADMIN_EMAIL`, `PGADMIN_PASSWORD` = pgAdmin credentials
### 4. Start WireGuard First
```bash
# Enable kernel module
sudo modprobe wireguard
# Start WireGuard
docker compose up -d wireguard
# Wait 10 seconds
sleep 10
# Check status
docker compose logs wireguard
```
### 5. Setup VPN Client (on your laptop/desktop)
**Access WireGuard Dashboard:** `http://YOUR_VPS_IP:51821`
1. Login with password from step 3
2. Click "New Client"
3. Name it (e.g., "MyLaptop")
4. Download config or scan QR code
**Install WireGuard Client:**
- Windows: https://www.wireguard.com/install/
- macOS: App Store
- Linux: `sudo apt install wireguard`
- Mobile: App Store / Play Store
**Import config and CONNECT**
### 6. Verify VPN Works
```bash
# From your local machine (while connected to VPN)
ping 10.13.13.1
```
Should get responses ✅
### 7. Secure WireGuard Dashboard
Edit `docker-compose.yaml`:
```yaml
# Find wireguard service, change:
ports:
- "51821:51821/tcp"
# To:
ports:
- "10.13.13.1:51821:51821/tcp"
```
```bash
docker compose down
docker compose up -d wireguard
```
### 8. Start All Services
```bash
# Make sure you're connected to VPN!
docker compose up -d
```
### 9. Initialize Application
```bash
# Generate app key
docker compose exec app php artisan key:generate
# Run migrations
docker compose exec app php artisan migrate --force
# Cache config
docker compose exec app php artisan config:cache
```
### 10. Access Your Services
**While connected to VPN:**
| Service | URL |
|---------|-----|
| **Laravel App** | http://10.13.13.1 |
| **Portainer** | http://10.13.13.1:9000 |
| **pgAdmin** | http://10.13.13.1:5050 |
| **WireGuard UI** | http://10.13.13.1:51821 |
## Firewall Configuration
```bash
sudo ufw allow 22/tcp # SSH
sudo ufw allow 51820/udp # WireGuard VPN
sudo ufw enable
```
**That's it!** ✅
## Adding More VPN Clients
1. Connect to VPN
2. Open: `http://10.13.13.1:51821`
3. Click "New Client"
4. Download config
5. Import on new device
## Troubleshooting
**Can't connect to VPN:**
```bash
docker compose logs wireguard
sudo ufw status
```
**Can't access app after VPN connection:**
```bash
ping 10.13.13.1
docker compose ps
docker compose logs nginx
```
**Check which ports are exposed:**
```bash
docker compose ps
sudo netstat -tulpn | grep 10.13.13.1
```
## Full Documentation
See `DEPLOYMENT_GUIDE.md` for complete setup instructions, SSL configuration, automated deployments, and troubleshooting.
+398
View File
@@ -0,0 +1,398 @@
# Reports Backend Rework Plan
## Overview
Transform the current hardcoded report system into a flexible, database-driven architecture that allows dynamic report configuration without code changes.
## Current Architecture Analysis
### Existing Structure
- **Report Classes**: Individual PHP classes (`ActiveContractsReport`, `ActivitiesPerPeriodReport`, etc.)
- **Registry Pattern**: `ReportRegistry` stores report instances in memory
- **Service Provider**: `ReportServiceProvider` registers reports at boot time
- **Base Class**: `BaseEloquentReport` provides common pagination logic
- **Contract Interface**: `Report` interface defines required methods (`slug`, `name`, `description`, `inputs`, `columns`, `query`)
- **Controller**: `ReportController` handles index, show, data, export routes
### Current Features
1. **Report Definition**: Each report defines:
- Slug (unique identifier)
- Name & Description
- Input parameters (filters)
- Column definitions
- Eloquent query builder
2. **Filter Types**: `date`, `string`, `select:client`, etc.
3. **Export**: PDF and CSV export functionality
4. **Pagination**: Server-side pagination support
## Proposed New Architecture
### 1. Database Schema
#### `reports` Table
```sql
CREATE TABLE reports (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
slug VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
category VARCHAR(100) NULL, -- e.g., 'contracts', 'activities', 'financial'
enabled BOOLEAN DEFAULT TRUE,
order INT DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP,
INDEX idx_slug (slug),
INDEX idx_enabled_order (enabled, order)
);
```
#### `report_entities` Table
Defines which database entities (models) the report queries.
```sql
CREATE TABLE report_entities (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
model_class VARCHAR(255) NOT NULL, -- e.g., 'App\Models\Contract'
alias VARCHAR(50) NULL, -- table alias for joins
join_type ENUM('base', 'join', 'leftJoin', 'rightJoin') DEFAULT 'base',
join_first VARCHAR(100) NULL, -- first column for join
join_operator VARCHAR(10) NULL, -- =, !=, etc.
join_second VARCHAR(100) NULL, -- second column for join
order INT DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_order (report_id, order)
);
```
#### `report_columns` Table
Defines selectable columns and their presentation.
```sql
CREATE TABLE report_columns (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
key VARCHAR(100) NOT NULL, -- column identifier
label VARCHAR(255) NOT NULL, -- display label
type VARCHAR(50) DEFAULT 'string', -- string, number, date, boolean, currency
expression TEXT NOT NULL, -- SQL expression or column path
sortable BOOLEAN DEFAULT TRUE,
visible BOOLEAN DEFAULT TRUE,
order INT DEFAULT 0,
format_options JSON NULL, -- { "decimals": 2, "prefix": "$" }
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_order (report_id, order)
);
```
#### `report_filters` Table
Defines available filter parameters.
```sql
CREATE TABLE report_filters (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
key VARCHAR(100) NOT NULL, -- filter identifier
label VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL, -- date, string, select, multiselect, number, boolean
nullable BOOLEAN DEFAULT TRUE,
default_value TEXT NULL,
options JSON NULL, -- For select/multiselect: [{"label":"...", "value":"..."}]
data_source VARCHAR(255) NULL, -- e.g., 'clients', 'segments' for dynamic selects
order INT DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_order (report_id, order)
);
```
#### `report_conditions` Table
Defines WHERE clause conditions (rules) for filtering data.
```sql
CREATE TABLE report_conditions (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
column VARCHAR(255) NOT NULL, -- e.g., 'contracts.start_date'
operator VARCHAR(50) NOT NULL, -- =, !=, >, <, >=, <=, LIKE, IN, BETWEEN, IS NULL, etc.
value_type VARCHAR(50) NOT NULL, -- static, filter, expression
value TEXT NULL, -- static value or expression
filter_key VARCHAR(100) NULL, -- references report_filters.key
logical_operator ENUM('AND', 'OR') DEFAULT 'AND',
group_id INT NULL, -- for grouping conditions (AND within group, OR between groups)
order INT DEFAULT 0,
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_group (report_id, group_id, order)
);
```
#### `report_orders` Table
Defines default ORDER BY clauses.
```sql
CREATE TABLE report_orders (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
column VARCHAR(255) NOT NULL,
direction ENUM('ASC', 'DESC') DEFAULT 'ASC',
order INT DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_order (report_id, order)
);
```
### 2. Model Structure
#### Report Model
```php
class Report extends Model
{
protected $fillable = ['slug', 'name', 'description', 'category', 'enabled', 'order'];
protected $casts = ['enabled' => 'boolean'];
public function entities(): HasMany { return $this->hasMany(ReportEntity::class); }
public function columns(): HasMany { return $this->hasMany(ReportColumn::class); }
public function filters(): HasMany { return $this->hasMany(ReportFilter::class); }
public function conditions(): HasMany { return $this->hasMany(ReportCondition::class); }
public function orders(): HasMany { return $this->hasMany(ReportOrder::class); }
}
```
### 3. Query Builder Service
Create `ReportQueryBuilder` service to dynamically construct queries:
```php
class ReportQueryBuilder
{
public function build(Report $report, array $filters = []): Builder
{
// 1. Start with base model query
// 2. Apply joins from report_entities
// 3. Select columns from report_columns
// 4. Apply conditions from report_conditions
// 5. Apply filter values to parameterized conditions
// 6. Apply ORDER BY from report_orders
// 7. Return Builder instance
}
}
```
### 4. Backward Compatibility Layer
Keep existing Report classes but load from database:
```php
class DatabaseReport extends BaseEloquentReport implements Report
{
public function __construct(protected Report $dbReport) {}
public function slug(): string { return $this->dbReport->slug; }
public function name(): string { return $this->dbReport->name; }
public function description(): ?string { return $this->dbReport->description; }
public function inputs(): array {
return $this->dbReport->filters()
->orderBy('order')
->get()
->map(fn($f) => [
'key' => $f->key,
'type' => $f->type,
'label' => $f->label,
'nullable' => $f->nullable,
'default' => $f->default_value,
'options' => $f->options,
])
->toArray();
}
public function columns(): array {
return $this->dbReport->columns()
->where('visible', true)
->orderBy('order')
->get()
->map(fn($c) => ['key' => $c->key, 'label' => $c->label])
->toArray();
}
public function query(array $filters): Builder {
return app(ReportQueryBuilder::class)->build($this->dbReport, $filters);
}
}
```
### 5. Migration Strategy
#### Phase 1: Database Setup
1. Create migrations for all new tables
2. Create models with relationships
3. Create seeders to migrate existing hardcoded reports
#### Phase 2: Service Layer
1. Build `ReportQueryBuilder` service
2. Build `DatabaseReport` adapter class
3. Update `ReportRegistry` to load from database
4. Create report management CRUD (admin UI)
#### Phase 3: Testing & Validation
1. Unit tests for query builder
2. Integration tests comparing old vs new results
3. Performance benchmarks
4. Export functionality validation
#### Phase 4: Migration Seeder
1. Create seeder that converts each hardcoded report into database records
2. Example for `ActiveContractsReport`:
```php
$report = Report::create([
'slug' => 'active-contracts',
'name' => 'Aktivne pogodbe',
'description' => 'Pogodbe, ki so aktivne...',
'enabled' => true,
]);
// Add entities (joins)
$report->entities()->create([
'model_class' => 'App\Models\Contract',
'join_type' => 'base',
'order' => 0,
]);
$report->entities()->create([
'model_class' => 'App\Models\ClientCase',
'join_type' => 'join',
'join_first' => 'contracts.client_case_id',
'join_operator' => '=',
'join_second' => 'client_cases.id',
'order' => 1,
]);
// Add columns
$report->columns()->create([
'key' => 'contract_reference',
'label' => 'Pogodba',
'expression' => 'contracts.reference',
'order' => 0,
]);
// Add filters
$report->filters()->create([
'key' => 'client_uuid',
'label' => 'Stranka',
'type' => 'select',
'data_source' => 'clients',
'nullable' => true,
'order' => 0,
]);
// Add conditions
$report->conditions()->create([
'column' => 'contracts.start_date',
'operator' => '<=',
'value_type' => 'expression',
'value' => 'CURRENT_DATE',
'group_id' => 1,
'order' => 0,
]);
```
#### Phase 5: Remove Old Report System
Once the new database-driven system is validated and working:
1. **Delete Hardcoded Report Classes**:
- Remove `app/Reports/ActiveContractsReport.php`
- Remove `app/Reports/ActivitiesPerPeriodReport.php`
- Remove `app/Reports/ActionsDecisionsCountReport.php`
- Remove `app/Reports/DecisionsCountReport.php`
- Remove `app/Reports/FieldJobsCompletedReport.php`
- Remove `app/Reports/SegmentActivityCountsReport.php`
2. **Remove Base Classes/Interfaces** (if no longer needed):
- Remove `app/Reports/BaseEloquentReport.php`
- Remove `app/Reports/Contracts/Report.php` interface
3. **Remove/Update Service Provider**:
- Remove `app/Providers/ReportServiceProvider.php`
- Or update it to only load reports from database
4. **Update ReportRegistry**:
- Modify to load from database instead of manual registration
- Remove all hardcoded `register()` calls
5. **Clean Up Config**:
- Remove any report-specific configuration files if they exist
- Update `bootstrap/providers.php` to remove ReportServiceProvider
6. **Documentation Cleanup**:
- Update any documentation referencing old report classes
- Add migration guide for future report creation
### 6. Admin UI for Report Management
Create CRUD interface at `Settings/Reports/*`:
- **Index**: List all reports with enable/disable toggle
- **Create**: Wizard-style form for building new reports
- **Edit**: Visual query builder interface
- **Test**: Preview report results
- **Clone**: Duplicate existing report as starting point
### 7. Advanced Features (Future)
1. **Calculated Fields**: Allow expressions like `(column_a + column_b) / 2`
2. **Aggregations**: Support SUM, AVG, COUNT, MIN, MAX
3. **Subqueries**: Define subquery relationships
4. **Report Templates**: Predefined report structures
5. **Scheduled Reports**: Email reports on schedule
6. **Report Sharing**: Share reports with specific users/roles
7. **Version History**: Track report definition changes
8. **Report Permissions**: Control who can view/edit reports
## Benefits
1. **No Code Changes**: Add/modify reports through UI
2. **Flexibility**: Non-developers can create reports
3. **Consistency**: All reports follow same structure
4. **Maintainability**: Centralized report logic
5. **Reusability**: Share entities, filters, conditions
6. **Version Control**: Track changes to report definitions
7. **Performance**: Optimize query builder once
8. **Export**: Works with any report automatically
## Risks & Considerations
1. **Complexity**: Query builder must handle diverse SQL patterns
2. **Performance**: Dynamic query building overhead
3. **Security**: SQL injection risks with user input
4. **Learning Curve**: Team needs to understand new system
5. **Testing**: Comprehensive test suite required
6. **Migration**: Convert all existing reports correctly
7. **Edge Cases**: Complex queries may be difficult to represent
## Timeline Estimate
- **Phase 1 (Database)**: 2-3 days
- **Phase 2 (Services)**: 4-5 days
- **Phase 3 (Testing)**: 2-3 days
- **Phase 4 (Migration)**: 1-2 days
- **Phase 5 (Cleanup)**: 1 day
- **Admin UI**: 3-4 days
- **Total**: 13-18 days
## Success Criteria
1. ✅ All existing reports work identically
2. ✅ New reports can be created via UI
3. ✅ Export functionality preserved
4. ✅ Performance within 10% of current
5. ✅ Zero SQL injection vulnerabilities
6. ✅ Comprehensive test coverage (>80%)
7. ✅ Documentation complete
+528
View File
@@ -0,0 +1,528 @@
# Reports Frontend Rework Plan
## Overview
This plan outlines the modernization of Reports frontend pages (`Index.vue` and `Show.vue`) using shadcn-vue components and AppCard containers, following the same patterns established in the Settings pages rework.
## Current State Analysis
### Reports/Index.vue (30 lines)
**Current Implementation:**
- Simple grid layout with native divs
- Report cards: `border rounded-lg p-4 bg-white shadow-sm hover:shadow-md`
- Grid: `md:grid-cols-2 lg:grid-cols-3`
- Each card shows: name (h2), description (p), Link to report
- **No shadcn-vue components used**
**Identified Issues:**
- Native HTML/Tailwind instead of shadcn-vue Card
- Inconsistent with Settings pages styling
- No icons for visual interest
- Basic hover effects only
### Reports/Show.vue (314 lines)
**Current Implementation:**
- Complex page with filters, export buttons, and data table
- Header section: title, description, export buttons (lines 190-196)
- Buttons: `px-3 py-2 rounded bg-gray-200 hover:bg-gray-300`
- Filter section: grid layout `md:grid-cols-4` (lines 218-270)
- Native inputs: `border rounded px-2 py-1`
- Native selects: `border rounded px-2 py-1`
- DatePicker component (already working)
- Filter buttons: Apply (`bg-indigo-600`) and Reset (`bg-gray-100`)
- Data table: DataTableServer component (lines 285-300)
- Formatting functions: formatNumberEU, formatDateEU, formatDateTimeEU, formatCell
**Identified Issues:**
- No Card containers for sections
- Native buttons instead of shadcn Button
- Native input/select elements instead of shadcn Input/Select
- No visual separation between sections
- Filter section could be extracted to partial
## Target Architecture
### Pattern Reference from Settings Pages
**Settings/Index.vue Pattern:**
```vue
<Card class="hover:shadow-lg transition-shadow">
<CardHeader>
<div class="flex items-center gap-2">
<component :is="icon" class="h-5 w-5 text-muted-foreground" />
<CardTitle>Title</CardTitle>
</div>
<CardDescription>Description</CardDescription>
</CardHeader>
<CardContent>
<Button variant="ghost">Action →</Button>
</CardContent>
</Card>
```
**Settings/Archive/Index.vue Pattern:**
- Uses AppCard for main container
- Extracted partials: ArchiveRuleCard, CreateRuleForm, EditRuleForm
- Alert components for warnings
- Badge components for status indicators
## Implementation Plan
### Phase 1: Reports/Index.vue Rework (Simple)
**Goal:** Replace native divs with shadcn-vue Card components, add icons
**Changes:**
1. **Import shadcn-vue components:**
```js
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { BarChart3, FileText, Activity, Users, TrendingUp, Calendar } from "lucide-vue-next";
```
2. **Add icon mapping for reports:**
```js
const reportIcons = {
'contracts': FileText,
'field': TrendingUp,
'activities': Activity,
// fallback icon
default: BarChart3,
};
function getReportIcon(category) {
return reportIcons[category] || reportIcons.default;
}
```
3. **Replace report card structure:**
- Remove native `<div class="border rounded-lg p-4 bg-white shadow-sm hover:shadow-md">`
- Use `<Card class="hover:shadow-lg transition-shadow cursor-pointer">`
- Structure:
```vue
<Card>
<CardHeader>
<div class="flex items-center gap-2">
<component :is="getReportIcon(report.category)" class="h-5 w-5 text-muted-foreground" />
<CardTitle>{{ report.name }}</CardTitle>
</div>
<CardDescription>{{ report.description }}</CardDescription>
</CardHeader>
<CardContent>
<Link :href="route('reports.show', report.slug)">
<Button variant="ghost" size="sm" class="w-full justify-start">
Odpri →
</Button>
</Link>
</CardContent>
</Card>
```
4. **Update page header:**
- Wrap in proper container with consistent spacing
- Match Settings/Index.vue header style
**Estimated Changes:**
- Lines: 30 → ~65 lines (with imports and icon logic)
- Files modified: 1 (Index.vue)
- Files created: 0
**Risk Level:** Low (simple page, straightforward replacement)
---
### Phase 2: Reports/Show.vue Rework - Structure (Medium)
**Goal:** Add Card containers for sections, replace native buttons
**Changes:**
1. **Import shadcn-vue components:**
```js
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Label } from "@/Components/ui/label";
import { Badge } from "@/Components/ui/badge";
import { Separator } from "@/Components/ui/separator";
import { Download, Filter, RotateCcw } from "lucide-vue-next";
```
2. **Wrap header + export buttons in Card:**
```vue
<Card class="mb-6">
<CardHeader>
<div class="flex items-start justify-between">
<div>
<CardTitle>{{ name }}</CardTitle>
<CardDescription v-if="description">{{ description }}</CardDescription>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" @click="exportFile('csv')">
<Download class="mr-2 h-4 w-4" />
CSV
</Button>
<Button variant="outline" size="sm" @click="exportFile('pdf')">
<Download class="mr-2 h-4 w-4" />
PDF
</Button>
<Button variant="outline" size="sm" @click="exportFile('xlsx')">
<Download class="mr-2 h-4 w-4" />
Excel
</Button>
</div>
</div>
</CardHeader>
</Card>
```
3. **Wrap filters in Card:**
```vue
<Card class="mb-6">
<CardHeader>
<div class="flex items-center gap-2">
<Filter class="h-5 w-5 text-muted-foreground" />
<CardTitle>Filtri</CardTitle>
</div>
</CardHeader>
<CardContent>
<!-- Filter grid here -->
<div class="grid gap-4 md:grid-cols-4">
<!-- Filter inputs -->
</div>
<Separator class="my-4" />
<div class="flex gap-2">
<Button @click="applyFilters">
<Filter class="mr-2 h-4 w-4" />
Prikaži
</Button>
<Button variant="outline" @click="resetFilters">
<RotateCcw class="mr-2 h-4 w-4" />
Ponastavi
</Button>
</div>
</CardContent>
</Card>
```
4. **Wrap DataTableServer in Card:**
```vue
<Card>
<CardHeader>
<CardTitle>Rezultati</CardTitle>
<CardDescription>
Skupaj {{ meta?.total || 0 }} {{ meta?.total === 1 ? 'rezultat' : 'rezultatov' }}
</CardDescription>
</CardHeader>
<CardContent>
<DataTableServer
<!-- props -->
/>
</CardContent>
</Card>
```
5. **Replace all native buttons with shadcn Button:**
- Export buttons: `variant="outline" size="sm"`
- Apply filter button: default variant
- Reset button: `variant="outline"`
**Estimated Changes:**
- Lines: 314 → ~350 lines (with imports and Card wrappers)
- Files modified: 1 (Show.vue)
- Files created: 0
- **Keep formatting functions unchanged** (working correctly)
**Risk Level:** Low-Medium (more complex but no logic changes)
---
### Phase 3: Reports/Show.vue - Replace Native Inputs (Medium)
**Goal:** Replace native input/select elements with shadcn-vue components
**Changes:**
1. **Replace date inputs:**
```vue
<!-- Keep DatePicker as-is (already working) -->
<div class="space-y-2">
<Label>{{ inp.label || inp.key }}</Label>
<DatePicker
v-model="filters[inp.key]"
format="dd.MM.yyyy"
placeholder="Izberi datum"
/>
</div>
```
2. **Replace text/number inputs:**
```vue
<div class="space-y-2">
<Label>{{ inp.label || inp.key }}</Label>
<Input
v-model="filters[inp.key]"
:type="inp.type === 'integer' ? 'number' : 'text'"
placeholder="Vnesi vrednost"
/>
</div>
```
3. **Replace select inputs (user/client):**
```vue
<div class="space-y-2">
<Label>{{ inp.label || inp.key }}</Label>
<Select v-model="filters[inp.key]">
<SelectTrigger>
<SelectValue placeholder="— brez —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">— brez —</SelectItem>
<SelectItem v-for="u in userOptions" :key="u.id" :value="u.id">
{{ u.name }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="userLoading" class="text-xs text-muted-foreground">Nalagam…</div>
</div>
```
4. **Update filter grid layout:**
- Change from `md:grid-cols-4` to `md:grid-cols-2 lg:grid-cols-4`
- Use `space-y-2` for label/input spacing
- Consistent gap: `gap-4`
**Estimated Changes:**
- Lines: ~350 → ~380 lines (shadcn Input/Select have more markup)
- Files modified: 1 (Show.vue)
- Files created: 0
**Risk Level:** Medium (v-model binding changes, test thoroughly)
---
### Phase 4: Optional - Extract Filter Section Partial (Optional)
**Goal:** Reduce Show.vue complexity by extracting filter logic
**Decision Criteria:**
- If filter section exceeds ~80 lines → extract to partial
- If multiple filter types need separate handling → extract
**Potential Partial Structure:**
```
resources/js/Pages/Reports/Partials/
FilterSection.vue
```
**FilterSection.vue:**
- Props: `inputs`, `filters` (reactive object), `userOptions`, `clientOptions`, `loading states`
- Emits: `@apply`, `@reset`
- Contains: entire filter grid + buttons
**Benefits:**
- Show.vue reduced from ~380 lines to ~300 lines
- Filter logic isolated and reusable
- Easier to maintain filter types
**Risks:**
- Adds complexity with props/emits
- Might not be worth it if filter logic is simple
**Recommendation:** Evaluate after Phase 3 completion. If filter section is clean and under 80 lines, skip this phase.
---
## Component Inventory
### shadcn-vue Components Needed
**Already Installed (verify):**
- Card, CardHeader, CardTitle, CardDescription, CardContent
- Button
- Input
- Select, SelectTrigger, SelectValue, SelectContent, SelectItem
- Label
- Badge
- Separator
**Need to Check:**
- lucide-vue-next icons (Download, Filter, RotateCcw, BarChart3, FileText, Activity, TrendingUp, Calendar)
### Custom Components
- AppCard (if needed for consistency)
- DatePicker (already working, keep as-is)
- DataTableServer (keep as-is)
---
## Testing Checklist
### Reports/Index.vue Testing:
- [ ] Cards display with correct icons
- [ ] Card hover effects work
- [ ] Links navigate to correct report
- [ ] Grid layout responsive (2 cols MD, 3 cols LG)
- [ ] Icons match report categories
### Reports/Show.vue Testing:
- [ ] Header Card displays title, description, export buttons
- [ ] Export buttons work (CSV, PDF, Excel)
- [ ] Filter Card displays all filter inputs correctly
- [ ] Date filters use DatePicker component
- [ ] User/Client selects load options async
- [ ] Apply filters button triggers report refresh
- [ ] Reset button clears all filters
- [ ] DataTableServer Card displays results
- [ ] Formatting functions work (dates, numbers, currencies)
- [ ] Pagination works
- [ ] All 6 reports render correctly:
- [ ] active-contracts
- [ ] field-jobs-completed
- [ ] decisions-counts
- [ ] segment-activity-counts
- [ ] actions-decisions-counts
- [ ] activities-per-period
---
## Implementation Order
### Step 1: Reports/Index.vue (30 min)
1. Import shadcn-vue components + icons
2. Add icon mapping function
3. Replace native divs with Card structure
4. Test navigation and layout
5. Verify responsive grid
### Step 2: Reports/Show.vue - Structure (45 min)
1. Import shadcn-vue components + icons
2. Wrap header + exports in Card
3. Wrap filters in Card
4. Wrap DataTableServer in Card
5. Replace all native buttons
6. Test all 6 reports
### Step 3: Reports/Show.vue - Inputs (60 min)
1. Replace text/number inputs with shadcn Input
2. Replace select inputs with shadcn Select
3. Add Label components
4. Test v-model bindings
5. Test async user/client loading
6. Test filter apply/reset
7. Verify all filter types work
### Step 4: Optional Partial Extraction (30 min, if needed)
1. Create FilterSection.vue partial
2. Move filter logic to partial
3. Set up props/emits
4. Test with all reports
### Step 5: Final Testing (30 min)
1. Test complete workflow (Index → Show → Filters → Export)
2. Verify all 6 reports
3. Test responsive layouts (mobile, tablet, desktop)
4. Check formatting consistency
5. Verify no regressions
**Total Estimated Time:** 2.5 - 3.5 hours
---
## Risk Assessment
### Low Risk:
- Index.vue rework (simple structure, straightforward replacement)
- Adding Card containers to Show.vue
- Replacing native buttons with shadcn Button
### Medium Risk:
- Replacing native inputs with shadcn Input/Select
- v-model bindings might need adjustments
- Async select loading needs testing
- Number input behavior might differ
### Mitigation Strategies:
1. Test each phase incrementally
2. Keep formatting functions unchanged (already working)
3. Test v-model bindings immediately after input replacement
4. Verify async loading with console logs
5. Test all 6 reports after each phase
6. Keep git commits small and atomic
---
## Success Criteria
### Functional Requirements:
✅ All reports navigate from Index page
✅ All filters work correctly (date, text, number, user select, client select)
✅ Apply filters refreshes report data
✅ Reset filters clears all inputs
✅ Export buttons generate CSV/PDF/Excel files
✅ DataTableServer displays results correctly
✅ Pagination works
✅ Formatting functions work (dates, numbers)
### Visual Requirements:
✅ Consistent Card-based layout
✅ shadcn-vue components throughout
✅ Icons for visual interest
✅ Hover effects on cards
✅ Proper spacing and alignment
✅ Responsive layout (mobile, tablet, desktop)
✅ Matches Settings pages style
### Code Quality:
✅ No code duplication
✅ Clean component imports
✅ Consistent naming conventions
✅ Proper TypeScript/Vue 3 patterns
✅ Formatting functions unchanged
✅ No regressions in functionality
---
## Notes
- **DatePicker component:** Already working, imported correctly, no changes needed
- **Formatting functions:** Keep unchanged (formatNumberEU, formatDateEU, formatDateTimeEU, formatCell)
- **DataTableServer:** Keep as-is, already working well
- **Async loading:** User/client select loading works, just needs shadcn Select wrapper
- **Pattern consistency:** Follow Settings/Index.vue and Settings/Archive/Index.vue patterns
- **Icon usage:** Add icons to Index.vue for visual interest, use lucide-vue-next
- **Button variants:** Use `variant="outline"` for secondary actions, default for primary
---
## Post-Implementation
After completing all phases:
1. **Documentation:**
- Update this document with actual implementation notes
- Document any deviations from plan
- Note any unexpected issues
2. **Code Review:**
- Check for consistent component usage
- Verify no native HTML/CSS buttons/inputs remain
- Ensure proper import structure
3. **User Feedback:**
- Test with actual users
- Gather feedback on UI improvements
- Note any requested adjustments
4. **Performance:**
- Verify no performance regressions
- Check bundle size impact
- Monitor async loading times
---
## Conclusion
This plan provides a structured approach to modernizing the Reports frontend pages using shadcn-vue components. The phased approach allows for incremental testing and reduces risk. The estimated total time is 2.5-3.5 hours, with low to medium risk level.
**Recommendation:** Start with Phase 1 (Index.vue) as a proof of concept, then proceed to Phase 2 and 3 for Show.vue. Evaluate Phase 4 (partial extraction) after Phase 3 completion based on actual complexity.
+2 -3
View File
@@ -24,15 +24,14 @@ public function build($options = null)
->get(); ->get();
$months = $data->pluck('month')->map( $months = $data->pluck('month')->map(
fn($nu) fn ($nu) => \DateTime::createFromFormat('!m', $nu)->format('F'))->toArray();
=> \DateTime::createFromFormat('!m', $nu)->format('F'))->toArray();
$newCases = $data->pluck('count')->toArray(); $newCases = $data->pluck('count')->toArray();
return $this->chart->areaChart() return $this->chart->areaChart()
->setTitle('Novi primeri zadnjih šest mesecev.') ->setTitle('Novi primeri zadnjih šest mesecev.')
->addData('Primeri', $newCases) ->addData('Primeri', $newCases)
//->addData('Completed', [7, 2, 7, 2, 5, 4]) // ->addData('Completed', [7, 2, 7, 2, 5, 4])
->setColors(['#ff6384']) ->setColors(['#ff6384'])
->setXAxis($months) ->setXAxis($months)
->setToolbar(true) ->setToolbar(true)
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Console\Commands;
use App\Models\Contract;
use App\Models\DocumentTemplate;
use App\Services\Documents\DocumentSettings;
use App\Services\Documents\TokenScanner;
use App\Services\Documents\TokenValueResolver;
use Illuminate\Console\Command;
class DocScanCommand extends Command
{
protected $signature = 'doc:scan {contract : Contract UUID} {xml : Path to Word document.xml}';
protected $description = 'Scan a Word document.xml for tokens and resolve values against a contract UUID';
public function handle(TokenScanner $scanner, TokenValueResolver $resolver, DocumentSettings $settings): int
{
$uuid = (string) $this->argument('contract');
$xmlPath = (string) $this->argument('xml');
if (! is_file($xmlPath)) {
$this->error("XML file not found: {$xmlPath}");
return self::FAILURE;
}
$xml = file_get_contents($xmlPath);
if ($xml === false) {
$this->error('Unable to read XML file.');
return self::FAILURE;
}
$contract = Contract::where('uuid', $uuid)->first();
if (! $contract) {
$this->error("Contract not found for UUID: {$uuid}");
return self::FAILURE;
}
// Normalize common Word run boundaries so tokens appear contiguous
$norm = $this->normalizeRunsForTokens($xml);
$tokens = $scanner->scan($norm);
$this->info('Detected tokens:');
foreach ($tokens as $t) {
$this->line(" - {$t}");
}
if (empty($tokens)) {
$this->warn('No tokens detected.');
}
// Build a minimal in-memory template using global whitelist so we can resolve values
$whitelist = $settings->get()->whitelist ?? [];
if (! is_array($whitelist)) {
$whitelist = [];
}
$entities = array_keys($whitelist);
$template = new DocumentTemplate([
'entities' => $entities,
'columns' => $whitelist,
'fail_on_unresolved' => false,
'formatting_options' => [],
'meta' => [],
]);
// Resolve values using a relaxed policy to avoid exceptions on unknowns
$user = auth()->user() ?? (\App\Models\User::query()->first() ?: new \App\Models\User(['name' => 'System']));
$resolved = $resolver->resolve($tokens, $template, $contract, $user, policy: 'blank');
$values = $resolved['values'] ?? [];
$unresolved = $resolved['unresolved'] ?? [];
$this->info('Resolved values:');
foreach ($values as $k => $v) {
$short = strlen((string) $v) > 120 ? substr((string) $v, 0, 117).'...' : (string) $v;
$this->line(" - {$k} => {$short}");
}
if (! empty($unresolved)) {
$this->warn('Unresolved tokens:');
foreach ($unresolved as $u) {
$this->line(" - {$u}");
}
}
return self::SUCCESS;
}
private function normalizeRunsForTokens(string $xml): string
{
// Remove proofing error spans that may split content
$xml = preg_replace('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
// Iteratively collapse boundaries between text runs, even if w:rPr is present
$patterns = [
// </w:t></w:r> [optional proofErr] <w:r ...> [optional rPr] <w:t>
'#</w:t>\s*</w:r>\s*(?:<w:proofErr[^>]*/>\s*)*(?:<w:r[^>]*>\s*(?:<w:rPr>.*?</w:rPr>\s*)*)?<w:t[^>]*>#is',
];
$prev = null;
while ($prev !== $xml) {
$prev = $xml;
foreach ($patterns as $pat) {
$xml = preg_replace($pat, '', $xml) ?? $xml;
}
}
// Remove zero-width and soft hyphen characters
$xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml);
return $xml;
}
}
@@ -0,0 +1,156 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class FixImportMappingEntities extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'import:fix-mapping-entities {--dry-run : Show changes without applying them}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fix entity names in import_mappings table to use canonical roots';
/**
* Entity name mappings from incorrect to correct canonical roots
*/
protected array $entityMapping = [
'contracts' => 'contract',
'contract' => 'contract',
'client_cases' => 'client_case',
'client_case' => 'client_case',
'person_addresses' => 'address',
'addresses' => 'address',
'address' => 'address',
'person_phones' => 'phone',
'phones' => 'phone',
'phone' => 'phone',
'emails' => 'email',
'email' => 'email',
'activities' => 'activity',
'activity' => 'activity',
'persons' => 'person',
'person' => 'person',
'accounts' => 'account',
'account' => 'account',
'payments' => 'payment',
'payment' => 'payment',
'bookings' => 'booking',
'booking' => 'booking',
];
/**
* Execute the console command.
*/
public function handle()
{
$dryRun = $this->option('dry-run');
if ($dryRun) {
$this->info('Running in DRY-RUN mode - no changes will be made');
}
$mappings = DB::table('import_mappings')
->whereNotNull('entity')
->where('entity', '!=', '')
->get();
if ($mappings->isEmpty()) {
$this->info('No mappings found to fix.');
return 0;
}
$this->info("Found {$mappings->count()} mappings to check");
$this->newLine();
$updates = [];
$unchanged = 0;
foreach ($mappings as $mapping) {
$currentEntity = trim($mapping->entity);
if (isset($this->entityMapping[$currentEntity])) {
$correctEntity = $this->entityMapping[$currentEntity];
if ($currentEntity !== $correctEntity) {
$updates[] = [
'id' => $mapping->id,
'current' => $currentEntity,
'correct' => $correctEntity,
'source' => $mapping->source_column,
'target' => $mapping->target_field,
];
} else {
$unchanged++;
}
} else {
$this->warn("Unknown entity type: {$currentEntity} (ID: {$mapping->id})");
}
}
if (empty($updates)) {
$this->info("✓ All {$unchanged} mappings already have correct entity names!");
return 0;
}
// Display changes
$this->info("Changes to be made:");
$this->newLine();
$table = [];
foreach ($updates as $update) {
$table[] = [
$update['id'],
$update['source'],
$update['target'],
$update['current'],
$update['correct'],
];
}
$this->table(
['ID', 'Source Column', 'Target Field', 'Current Entity', 'Correct Entity'],
$table
);
$this->newLine();
$this->info("Total changes: " . count($updates));
$this->info("Unchanged: {$unchanged}");
if ($dryRun) {
$this->newLine();
$this->warn('DRY-RUN mode: No changes were made. Run without --dry-run to apply changes.');
return 0;
}
// Confirm before proceeding
if (!$this->confirm('Do you want to apply these changes?', true)) {
$this->info('Operation cancelled.');
return 0;
}
// Apply updates
$updated = 0;
foreach ($updates as $update) {
DB::table('import_mappings')
->where('id', $update['id'])
->update(['entity' => $update['correct']]);
$updated++;
}
$this->newLine();
$this->info("✓ Successfully updated {$updated} mappings!");
return 0;
}
}
+2 -2
View File
@@ -2,12 +2,13 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Post; use App\Models\Post;
use Illuminate\Console\Command;
class ImportPosts extends Command class ImportPosts extends Command
{ {
protected $signature = 'import:posts'; protected $signature = 'import:posts';
protected $description = 'Import posts into Algolia without clearing the index'; protected $description = 'Import posts into Algolia without clearing the index';
public function __construct() public function __construct()
@@ -22,4 +23,3 @@ public function handle()
$this->info('Posts have been imported into Algolia.'); $this->info('Posts have been imported into Algolia.');
} }
} }
@@ -0,0 +1,113 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class PopulateImportMappingEntities extends Command
{
protected $signature = 'import:populate-mapping-entities {--dry-run : Show changes without applying them}';
protected $description = 'Populate entity column from target_field for mappings where entity is null';
protected array $entityMap = [
'contracts' => 'contract',
'client_cases' => 'client_case',
'person_addresses' => 'address',
'person_phones' => 'phone',
'emails' => 'email',
'activities' => 'activity',
'payments' => 'payment',
'accounts' => 'account',
'persons' => 'person',
'person' => 'person',
'contract' => 'contract',
'client_case' => 'client_case',
'address' => 'address',
'phone' => 'phone',
'email' => 'email',
'activity' => 'activity',
'payment' => 'payment',
'account' => 'account',
];
public function handle()
{
$dryRun = $this->option('dry-run');
$this->info('Populating entity column from target_field...');
if ($dryRun) {
$this->warn('DRY RUN MODE - No changes will be made');
}
// Get all mappings where entity is null
$mappings = DB::table('import_mappings')
->whereNull('entity')
->get();
if ($mappings->isEmpty()) {
$this->info('No mappings found with null entity.');
return 0;
}
$this->info("Found {$mappings->count()} mappings to process.");
$this->newLine();
$updated = 0;
$skipped = 0;
foreach ($mappings as $mapping) {
$targetField = $mapping->target_field;
// Parse the target_field to extract entity and field
if (str_contains($targetField, '.')) {
[$rawEntity, $field] = explode('.', $targetField, 2);
} elseif (str_contains($targetField, '->')) {
[$rawEntity, $field] = explode('->', $targetField, 2);
} else {
$this->warn("Skipping mapping ID {$mapping->id}: Cannot parse target_field '{$targetField}'");
$skipped++;
continue;
}
$rawEntity = trim($rawEntity);
$field = trim($field);
// Map to canonical entity name
$canonicalEntity = $this->entityMap[$rawEntity] ?? $rawEntity;
$this->line(sprintf(
"ID %d: '%s' -> '%s' => entity='%s', field='%s'",
$mapping->id,
$mapping->source_column,
$targetField,
$canonicalEntity,
$field
));
if (!$dryRun) {
DB::table('import_mappings')
->where('id', $mapping->id)
->update([
'entity' => $canonicalEntity,
'target_field' => $field,
]);
$updated++;
}
}
$this->newLine();
if ($dryRun) {
$this->info("Dry run complete. Would have updated {$mappings->count()} mappings.");
} else {
$this->info("Successfully updated {$updated} mappings.");
}
if ($skipped > 0) {
$this->warn("Skipped {$skipped} mappings that couldn't be parsed.");
}
return 0;
}
}
@@ -10,12 +10,15 @@
class PruneDocumentPreviews extends Command class PruneDocumentPreviews extends Command
{ {
protected $signature = 'documents:prune-previews {--days=90 : Delete previews older than this many days} {--dry-run : Show what would be deleted without deleting}'; protected $signature = 'documents:prune-previews {--days=90 : Delete previews older than this many days} {--dry-run : Show what would be deleted without deleting}';
protected $description = 'Deletes generated document preview files older than N days and clears their metadata.'; protected $description = 'Deletes generated document preview files older than N days and clears their metadata.';
public function handle(): int public function handle(): int
{ {
$days = (int) $this->option('days'); $days = (int) $this->option('days');
if ($days < 1) { $days = 90; } if ($days < 1) {
$days = 90;
}
$cutoff = Carbon::now()->subDays($days); $cutoff = Carbon::now()->subDays($days);
$previewDisk = config('files.preview_disk', 'public'); $previewDisk = config('files.preview_disk', 'public');
@@ -27,6 +30,7 @@ public function handle(): int
$count = $query->count(); $count = $query->count();
if ($count === 0) { if ($count === 0) {
$this->info('No stale previews found.'); $this->info('No stale previews found.');
return self::SUCCESS; return self::SUCCESS;
} }
@@ -36,9 +40,12 @@ public function handle(): int
$query->chunkById(200, function ($docs) use ($previewDisk, $dry) { $query->chunkById(200, function ($docs) use ($previewDisk, $dry) {
foreach ($docs as $doc) { foreach ($docs as $doc) {
$path = $doc->preview_path; $path = $doc->preview_path;
if (!$path) { continue; } if (! $path) {
continue;
}
if ($dry) { if ($dry) {
$this->line("Would delete: {$previewDisk}://{$path} (document #{$doc->id})"); $this->line("Would delete: {$previewDisk}://{$path} (document #{$doc->id})");
continue; continue;
} }
try { try {
@@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RefreshMaterializedViews extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reports:refresh-mviews {--concurrently : Use CONCURRENTLY (Postgres 9.4+; requires indexes)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Refresh configured Postgres materialized views for reporting';
/**
* Execute the console command.
*/
public function handle(): int
{
$views = (array) config('reports.materialized_views', []);
if (empty($views)) {
$this->info('No materialized views configured.');
return self::SUCCESS;
}
$concurrently = $this->option('concurrently') ? ' CONCURRENTLY' : '';
foreach ($views as $view) {
$name = trim((string) $view);
if ($name === '') {
continue;
}
$sql = 'REFRESH MATERIALIZED VIEW'.$concurrently.' '.DB::getPdo()->quote($name);
// PDO::quote wraps with single quotes; for identifiers we need double quotes or no quotes.
// Use a safe fallback: wrap with " if not already quoted
$safe = 'REFRESH MATERIALIZED VIEW'.$concurrently.' "'.str_replace('"', '""', $name).'"';
try {
DB::statement($safe);
$this->info("Refreshed: {$name}");
} catch (\Throwable $e) {
$this->error("Failed to refresh {$name}: ".$e->getMessage());
}
}
return self::SUCCESS;
}
}
@@ -0,0 +1,145 @@
<?php
namespace App\Console\Commands;
use App\Models\Import;
use App\Services\Import\ImportSimulationServiceV2;
use Illuminate\Console\Command;
class SimulateImportV2Command extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'import:simulate-v2 {import_id} {--limit=100 : Number of rows to simulate} {--verbose : Include detailed information}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Simulate ImportServiceV2 without persisting data';
/**
* Execute the console command.
*/
public function handle(ImportSimulationServiceV2 $service): int
{
$importId = $this->argument('import_id');
$limit = (int) $this->option('limit');
$verbose = (bool) $this->option('verbose');
$import = Import::find($importId);
if (! $import) {
$this->error("Import #{$importId} not found.");
return 1;
}
$this->info("Simulating import #{$importId} - {$import->file_name}");
$this->info("Client: ".($import->client->name ?? 'N/A'));
$this->info("Limit: {$limit} rows");
$this->line('');
$result = $service->simulate($import, $limit, $verbose);
if (! $result['success']) {
$this->error('Simulation failed: '.$result['error']);
return 1;
}
$this->info("✓ Simulated {$result['total_simulated']} rows");
$this->line('');
// Display summaries
if (! empty($result['summaries'])) {
$this->info('=== Entity Summaries ===');
$summaryRows = [];
foreach ($result['summaries'] as $entity => $stats) {
$summaryRows[] = [
'entity' => $entity,
'create' => $stats['create'],
'update' => $stats['update'],
'skip' => $stats['skip'],
'invalid' => $stats['invalid'],
'total' => array_sum($stats),
];
}
$this->table(
['Entity', 'Create', 'Update', 'Skip', 'Invalid', 'Total'],
$summaryRows
);
$this->line('');
}
// Display row previews (first 5)
if (! empty($result['rows'])) {
$this->info('=== Row Previews (first 5) ===');
foreach (array_slice($result['rows'], 0, 5) as $row) {
$this->line("Row #{$row['row_number']}:");
if (! empty($row['entities'])) {
foreach ($row['entities'] as $entity => $data) {
$action = $data['action'];
$color = match ($action) {
'create' => 'green',
'update' => 'yellow',
'skip' => 'gray',
'invalid', 'error' => 'red',
default => 'white',
};
$line = " {$entity}: <fg={$color}>{$action}</>";
if (isset($data['reference'])) {
$line .= " ({$data['reference']})";
}
if (isset($data['existing_id'])) {
$line .= " [ID: {$data['existing_id']}]";
}
$this->line($line);
if ($verbose && ! empty($data['changes'])) {
foreach ($data['changes'] as $field => $change) {
$this->line(" {$field}: {$change['old']}{$change['new']}");
}
}
if (! empty($data['errors'])) {
foreach ($data['errors'] as $error) {
$this->error("{$error}");
}
}
}
}
if (! empty($row['warnings'])) {
foreach ($row['warnings'] as $warning) {
$this->warn("{$warning}");
}
}
if (! empty($row['errors'])) {
foreach ($row['errors'] as $error) {
$this->error("{$error}");
}
}
$this->line('');
}
}
$this->info('Simulation completed successfully.');
return 0;
}
}
@@ -0,0 +1,152 @@
<?php
namespace App\Console\Commands;
use App\Models\DocumentTemplate;
use App\Services\Documents\TokenScanner;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use ZipArchive;
class TemplateScanCommand extends Command
{
protected $signature = 'template:scan {slug : Template slug} {--tpl-version= : Specific template version number} {--parts : Show per-part tokens}';
protected $description = 'Scan a stored DOCX template by slug/version and dump detected tokens directly from storage.';
public function handle(TokenScanner $scanner): int
{
$slug = (string) $this->argument('slug');
$version = $this->option('tpl-version');
/** @var DocumentTemplate|null $template */
$query = DocumentTemplate::query()->where('slug', $slug);
if (! empty($version)) {
$query->where('version', (int) $version);
} else {
$query->orderByDesc('version');
}
$template = $query->first();
if (! $template) {
$this->error("Template not found for slug '{$slug}'".($version ? " v{$version}" : ''));
return self::FAILURE;
}
$disk = 'public';
$path = $template->file_path;
if (! $path || ! Storage::disk($disk)->exists($path)) {
$this->error('Template file not found on disk: '.$path);
return self::FAILURE;
}
$bytes = Storage::disk($disk)->get($path);
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
file_put_contents($tmp, $bytes);
$zip = new ZipArchive;
if ($zip->open($tmp) !== true) {
$this->error('Unable to open DOCX (zip).');
return self::FAILURE;
}
// Collect parts: main + headers/footers + notes/comments
$parts = [];
$doc = $zip->getFromName('word/document.xml');
if ($doc !== false) {
$parts['word/document.xml'] = $doc;
}
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (! is_string($name)) {
continue;
}
if (preg_match('#^word/(header\d*|footer\d*|footnotes|endnotes|comments)\.xml$#i', $name)) {
$xml = $zip->getFromName($name);
if ($xml !== false) {
$parts[$name] = $xml;
}
}
}
// Normalize and scan
$all = [];
$perPart = [];
foreach ($parts as $name => $xml) {
$norm = $this->normalizeRunsForTokens($xml);
$found = $scanner->scan($norm);
$perPart[$name] = $found;
if ($found) {
$all = array_merge($all, $found);
}
}
$union = array_values(array_unique($all));
$this->info("Template: {$template->name} (slug={$template->slug}, v{$template->version})");
$this->line('File: '.$path);
$this->line('Tokens found (union): '.count($union));
foreach ($union as $t) {
$this->line(' - '.$t);
}
if ($this->option('parts')) {
$this->line('');
$this->info('Per-part details:');
foreach ($perPart as $n => $list) {
$this->line("[{$n}] (".count($list).')');
foreach ($list as $t) {
$this->line(' - '.$t);
}
}
}
$zip->close();
@unlink($tmp);
return self::SUCCESS;
}
private function normalizeRunsForTokens(string $xml): string
{
// Remove proofing error markers
$xml = preg_replace('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
// Collapse boundaries between runs and inside runs (include tabs/line breaks)
$patterns = [
'#</w:t>\s*</w:r>\s*(?:<(?:w:proofErr|w:tab|w:br)[^>]*/>\s*)*(?:<w:r[^>]*>\s*(?:<w:rPr>.*?</w:rPr>\s*)*)?<w:t[^>]*>#is',
'#</w:t>\s*(?:<(?:w:proofErr|w:tab|w:br)[^>]*/>\s*)*<w:t[^>]*>#is',
];
$prev = null;
while ($prev !== $xml) {
$prev = $xml;
foreach ($patterns as $pat) {
$xml = preg_replace($pat, '', $xml) ?? $xml;
}
}
// Clean inside {{ ... }}
$xml = preg_replace_callback('/\{\{.*?\}\}/s', function (array $m) {
$inner = substr($m[0], 2, -2);
$inner = preg_replace('/<[^>]+>/', '', $inner) ?? $inner;
$inner = preg_replace('/\s+/', '', $inner) ?? $inner;
return '{{'.$inner.'}}';
}, $xml) ?? $xml;
// Clean inside { ... } if it looks like a token
$xml = preg_replace_callback('/\{[^{}]*\}/s', function (array $m) {
$raw = $m[0];
$inner = substr($raw, 1, -1);
$clean = preg_replace('/<[^>]+>/', '', $inner) ?? $inner;
$clean = preg_replace('/\s+/', '', $clean) ?? $clean;
if (preg_match('/^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+$/', $clean)) {
return '{'.$clean.'}';
}
return $raw;
}, $xml) ?? $xml;
// Remove zero-width and soft hyphen
$xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml);
return $xml;
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ProcessLargeImportJob;
use App\Models\Import;
use App\Services\Import\ImportServiceV2;
use Illuminate\Console\Command;
class TestImportV2Command extends Command
{
protected $signature = 'import:test-v2 {import_id : The import ID to process} {--queue : Process via queue}';
protected $description = 'Test ImportServiceV2 with an existing import';
public function handle()
{
$importId = $this->argument('import_id');
$useQueue = $this->option('queue');
$import = Import::find($importId);
if (! $import) {
$this->error("Import {$importId} not found.");
return 1;
}
$this->info("Processing import: {$import->id} ({$import->file_name})");
$this->info("Source: {$import->source_type}");
$this->info("Status: {$import->status}");
$this->newLine();
if ($useQueue) {
$this->info('Dispatching to queue...');
ProcessLargeImportJob::dispatch($import, auth()->id());
$this->info('Job dispatched successfully. Monitor queue for progress.');
return 0;
}
$this->info('Processing synchronously...');
$service = app(ImportServiceV2::class);
try {
$results = $service->process($import, auth()->user());
$this->newLine();
$this->info('Processing completed!');
$this->table(
['Metric', 'Count'],
[
['Total rows', $results['total']],
['Imported', $results['imported']],
['Skipped', $results['skipped']],
['Invalid', $results['invalid']],
]
);
return 0;
} catch (\Throwable $e) {
$this->error('Processing failed: '.$e->getMessage());
$this->error($e->getTraceAsString());
return 1;
}
}
}
+12 -1
View File
@@ -15,11 +15,22 @@ protected function schedule(Schedule $schedule): void
// Optionally prune old previews daily // Optionally prune old previews daily
if (config('files.enable_preview_prune', true)) { if (config('files.enable_preview_prune', true)) {
$days = (int) config('files.preview_retention_days', 90); $days = (int) config('files.preview_retention_days', 90);
if ($days < 1) { $days = 90; } if ($days < 1) {
$days = 90;
}
$schedule->command('documents:prune-previews', [ $schedule->command('documents:prune-previews', [
'--days' => $days, '--days' => $days,
])->dailyAt('02:00'); ])->dailyAt('02:00');
} }
// Optional: refresh configured materialized views for reporting
$views = (array) config('reports.materialized_views', []);
if (! empty($views)) {
$time = (string) (config('reports.refresh_time', '03:00') ?: '03:00');
$schedule->command('reports:refresh-mviews', [
'--concurrently' => true,
])->dailyAt($time);
}
} }
/** /**
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum PersonPhoneType: string
{
case Mobile = 'mobile';
case Landline = 'landline';
case Voip = 'voip';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Events;
use App\Models\Activity;
class ActivityDecisionApplied
{
public function __construct(public Activity $activity) {}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Events;
use App\Models\Contract;
use Illuminate\Foundation\Events\Dispatchable;
class ChangeContractSegment
{
use Dispatchable;
public function __construct(
public Contract $contract,
public int $segmentId,
public bool $deactivatePrevious = true,
) {}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
namespace App\Events;
use App\Models\ClientCase;
use App\Models\Segment;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ClientCaseToTerrain implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public ClientCase $clientCase;
/**
* Create a new event instance.
*/
public function __construct(ClientCase $clientCase)
{
$this->clientCase = $clientCase;
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel('segments'.$this->clientCase->id);
}
public function broadcastAs(){
return 'client_case.terrain.add';
}
}
-43
View File
@@ -1,43 +0,0 @@
<?php
namespace App\Events;
use App\Models\Contract;
use App\Models\Segment;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ContractToTerrain implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public Contract $contract;
public Segment $segment;
/**
* Create a new event instance.
*/
public function __construct(Contract $contract, Segment $segment)
{
//
$this->contract = $contract;
$this->segment = $segment;
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel('contracts.'.$this->segment->id);
}
public function broadcastAs(){
return 'contract.terrain.add';
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
namespace App\Exports;
use App\Models\Contract;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
use Maatwebsite\Excel\Concerns\WithCustomValueBinder;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
class ClientContractsExport extends DefaultValueBinder implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithCustomValueBinder, WithHeadings, WithMapping
{
public const DATE_EXCEL_FORMAT = 'dd"."mm"."yyyy';
public const TEXT_EXCEL_FORMAT = NumberFormat::FORMAT_TEXT;
/**
* @var array<string, string>
*/
private array $columnLetterMap = [];
/**
* @var array<string, array{label: string}>
*/
public const COLUMN_METADATA = [
'reference' => ['label' => 'Referenca'],
'customer' => ['label' => 'Stranka'],
'address' => ['label' => 'Naslov'],
'start' => ['label' => 'Začetek'],
'segment' => ['label' => 'Segment'],
'balance' => ['label' => 'Stanje'],
];
/**
* @param array<int, string> $columns
*/
public function __construct(private Builder $query, private array $columns) {}
/**
* @return array<int, string>
*/
public static function allowedColumns(): array
{
return array_keys(self::COLUMN_METADATA);
}
public static function columnLabel(string $column): string
{
return self::COLUMN_METADATA[$column]['label'] ?? $column;
}
public function query(): Builder
{
return $this->query;
}
/**
* @return array<int, mixed>
*/
public function map($row): array
{
return array_map(fn (string $column) => $this->resolveValue($row, $column), $this->columns);
}
/**
* @return array<int, string>
*/
public function headings(): array
{
return array_map(fn (string $column) => self::columnLabel($column), $this->columns);
}
/**
* @return array<string, string>
*/
public function columnFormats(): array
{
$formats = [];
foreach ($this->getColumnLetterMap() as $letter => $column) {
if ($column === 'reference') {
$formats[$letter] = self::TEXT_EXCEL_FORMAT;
continue;
}
if ($column === 'start') {
$formats[$letter] = self::DATE_EXCEL_FORMAT;
}
}
return $formats;
}
private function resolveValue(Contract $contract, string $column): mixed
{
return match ($column) {
'reference' => $contract->reference,
'customer' => optional($contract->clientCase?->person)->full_name,
'address' => optional($contract->clientCase?->person?->address)->address,
'start' => $this->formatDate($contract->start_date),
'segment' => $contract->segments?->first()?->name,
'balance' => optional($contract->account)->balance_amount,
default => null,
};
}
private function formatDate(?string $date): mixed
{
if (empty($date)) {
return null;
}
try {
$carbon = Carbon::parse($date);
return ExcelDate::dateTimeToExcel($carbon);
} catch (\Exception $e) {
return null;
}
}
/**
* @return array<string, string>
*/
private function getColumnLetterMap(): array
{
if ($this->columnLetterMap !== []) {
return $this->columnLetterMap;
}
$letter = 'A';
foreach ($this->columns as $column) {
$this->columnLetterMap[$letter] = $column;
$letter++;
}
return $this->columnLetterMap;
}
public function bindValue(Cell $cell, $value): bool
{
if (is_numeric($value)) {
$cell->setValueExplicit($value, DataType::TYPE_NUMERIC);
return true;
}
return parent::bindValue($cell, $value);
}
}
+172
View File
@@ -0,0 +1,172 @@
<?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 SegmentContractsExport 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' => 'Pogodba'],
'client_case' => ['label' => 'Primer'],
'address' => ['label' => 'Naslov'],
'client' => ['label' => 'Stranka'],
'type' => ['label' => 'Vrsta'],
'start_date' => ['label' => 'Začetek'],
'end_date' => ['label' => 'Konec'],
'account' => ['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 (in_array($column, ['start_date', 'end_date'], true)) {
$formats[$letter] = self::DATE_EXCEL_FORMAT;
}
}
return $formats;
}
private function resolveValue(Contract $contract, string $column): mixed
{
return match ($column) {
'reference' => $contract->reference,
'client_case' => optional($contract->clientCase?->person)->full_name,
'address' => optional($contract->clientCase?->person?->address)->address,
'client' => optional($contract->clientCase?->client?->person)->full_name,
'type' => optional($contract->type)->name,
'start_date' => $this->formatDate($contract->start_date),
'end_date' => $this->formatDate($contract->end_date),
'account' => optional($contract->account)->balance_amount,
default => null,
};
}
private function formatDate(mixed $value): ?float
{
$carbon = Carbon::make($value);
if (! $carbon) {
return null;
}
return ExcelDate::dateTimeToExcel($carbon->copy()->startOfDay());
}
private function columnLetter(int $index): string
{
$index++;
$letter = '';
while ($index > 0) {
$remainder = ($index - 1) % 26;
$letter = chr(65 + $remainder).$letter;
$index = intdiv($index - 1, 26);
}
return $letter;
}
public function bindValue(Cell $cell, $value): bool
{
$columnKey = $this->getColumnLetterMap()[$cell->getColumn()] ?? null;
if ($columnKey === 'reference') {
$cell->setValueExplicit((string) $value, DataType::TYPE_STRING);
return true;
}
return parent::bindValue($cell, $value);
}
/**
* @return array<string, string>
*/
private function getColumnLetterMap(): array
{
if ($this->columnLetterMap === []) {
foreach ($this->columns as $index => $column) {
$this->columnLetterMap[$this->columnLetter($index)] = $column;
}
}
return $this->columnLetterMap;
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
namespace App\Helpers;
class LZStringHelper
{
/**
* Decompresses a string compressed with LZ-String's compressToEncodedURIComponent method.
* This is a PHP port of the JavaScript LZ-String library.
*
* @param string $compressed
* @return string|null
*/
public static function decompressFromEncodedURIComponent($compressed)
{
if ($compressed === null || $compressed === '') {
return '';
}
// Replace URL-safe characters back
$compressed = str_replace(' ', '+', $compressed);
return self::decompress(strlen($compressed), 32, function ($index) use ($compressed) {
return self::getBaseValue(self::$keyStrUriSafe, $compressed[$index]);
});
}
private static $keyStrUriSafe = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$';
private static function getBaseValue($alphabet, $character)
{
$pos = strpos($alphabet, $character);
return $pos !== false ? $pos : -1;
}
private static function decompress($length, $resetValue, $getNextValue)
{
$dictionary = [];
$enlargeIn = 4;
$dictSize = 4;
$numBits = 3;
$entry = '';
$result = [];
$data = ['val' => $getNextValue(0), 'position' => $resetValue, 'index' => 1];
for ($i = 0; $i < 3; $i++) {
$dictionary[$i] = chr($i);
}
$bits = 0;
$maxpower = pow(2, 2);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$next = $bits;
switch ($next) {
case 0:
$bits = 0;
$maxpower = pow(2, 8);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$c = chr($bits);
break;
case 1:
$bits = 0;
$maxpower = pow(2, 16);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$c = chr($bits);
break;
case 2:
return '';
}
$dictionary[$dictSize++] = $c;
$w = $c;
$result[] = $c;
while (true) {
if ($data['index'] > $length) {
return '';
}
$bits = 0;
$maxpower = pow(2, $numBits);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$c = $bits;
switch ($c) {
case 0:
$bits = 0;
$maxpower = pow(2, 8);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$dictionary[$dictSize++] = chr($bits);
$c = $dictSize - 1;
$enlargeIn--;
break;
case 1:
$bits = 0;
$maxpower = pow(2, 16);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$dictionary[$dictSize++] = chr($bits);
$c = $dictSize - 1;
$enlargeIn--;
break;
case 2:
return implode('', $result);
}
if ($enlargeIn == 0) {
$enlargeIn = pow(2, $numBits);
$numBits++;
}
if (isset($dictionary[$c])) {
$entry = $dictionary[$c];
} else {
if ($c === $dictSize) {
$entry = $w.$w[0];
} else {
return null;
}
}
$result[] = $entry;
$dictionary[$dictSize++] = $w.$entry[0];
$enlargeIn--;
$w = $entry;
if ($enlargeIn == 0) {
$enlargeIn = pow(2, $numBits);
$numBits++;
}
}
}
}
@@ -2,10 +2,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Account;
use Illuminate\Http\Request;
use Inertia\Inertia;
class AccountController extends Controller class AccountController extends Controller
{ {
// //
@@ -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.');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Models\Activity;
use App\Models\ActivityNotificationRead;
use Illuminate\Http\Request;
class ActivityNotificationController extends Controller
{
/**
* Handle the incoming request.
*/
public function __invoke(Request $request)
{
$data = $request->validate([
'activity_id' => ['sometimes', 'integer', 'exists:activities,id'],
'activity_ids' => ['sometimes', 'array', 'min:1'],
'activity_ids.*' => ['integer', 'exists:activities,id'],
]);
$userId = optional($request->user())->id;
if (! $userId) {
abort(403);
}
$ids = [];
if (!empty($data['activity_id'])) {
$ids[] = $data['activity_id'];
}
if (!empty($data['activity_ids'])) {
$ids = array_merge($ids, $data['activity_ids']);
}
$ids = array_unique($ids);
$activities = Activity::query()->select(['id', 'due_date'])->whereIn('id', $ids)->get();
foreach ($activities as $activity) {
$due = optional($activity->due_date) ? date('Y-m-d', strtotime($activity->due_date)) : now()->toDateString();
ActivityNotificationRead::query()->updateOrCreate(
[
'user_id' => $userId,
'activity_id' => $activity->id,
'due_date' => $due,
],
[
'read_at' => now(),
]
);
}
return back();
}
}
@@ -46,6 +46,7 @@ public function toggleActive(DocumentTemplate $template)
public function show(DocumentTemplate $template) public function show(DocumentTemplate $template)
{ {
$this->ensurePermission(); $this->ensurePermission();
return Inertia::render('Admin/DocumentTemplates/Show', [ return Inertia::render('Admin/DocumentTemplates/Show', [
'template' => $template, 'template' => $template,
]); ]);
@@ -121,12 +122,105 @@ public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentT
if ($dirty) { if ($dirty) {
$template->formatting_options = $fmt; $template->formatting_options = $fmt;
} }
// Merge meta, including custom_defaults
if ($request->has('meta') && is_array($request->input('meta'))) {
$meta = array_filter($request->input('meta'), fn ($v) => $v !== null && $v !== '');
$template->meta = array_replace($template->meta ?? [], $meta);
}
$template->updated_by = Auth::id(); $template->updated_by = Auth::id();
$template->save(); $template->save();
return redirect()->back()->with('success', 'Nastavitve predloge shranjene.'); return redirect()->back()->with('success', 'Nastavitve predloge shranjene.');
} }
public function rescanTokens(DocumentTemplate $template)
{
$this->ensurePermission();
// Best-effort: read stored DOCX from disk and re-scan tokens
$tokens = [];
try {
/** @var TokenScanner $scanner */
$scanner = app(TokenScanner::class);
$zip = new \ZipArchive;
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
// Copy file from storage to a temp path
$disk = 'public';
$stream = \Storage::disk($disk)->get($template->file_path);
file_put_contents($tmp, $stream);
if ($zip->open($tmp) === true) {
// Collect main document and header/footer parts
$parts = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$name = $stat['name'] ?? '';
if (preg_match('#^word\/(document|header\d+|footer\d+)\.xml$#i', $name)) {
$parts[] = $name;
}
}
if (empty($parts)) {
$parts = ['word/document.xml'];
}
$found = [];
foreach ($parts as $name) {
$xml = $zip->getFromName($name);
if ($xml === false) {
continue;
}
$norm = self::normalizeDocxXmlTokens($xml);
$det = $scanner->scan($norm);
if (! empty($det)) {
$found = array_merge($found, $det);
}
}
$tokens = array_values(array_unique($found));
$zip->close();
}
} catch (\Throwable $e) {
// swallow scanning errors, keep $tokens as empty
}
if (\Schema::hasColumn('document_templates', 'tokens')) {
$template->tokens = $tokens;
}
// Auto-detect custom.* tokens and ensure meta.custom_default_types has defaults
try {
$meta = is_array($template->meta) ? $template->meta : [];
$types = isset($meta['custom_default_types']) && is_array($meta['custom_default_types']) ? $meta['custom_default_types'] : [];
$defaults = isset($meta['custom_defaults']) && is_array($meta['custom_defaults']) ? $meta['custom_defaults'] : [];
foreach (($tokens ?? []) as $tok) {
if (is_string($tok) && str_starts_with($tok, 'custom.')) {
$key = substr($tok, 7);
if ($key !== '') {
if (! array_key_exists($key, $types)) {
$types[$key] = 'string';
}
if (! array_key_exists($key, $defaults)) {
$defaults[$key] = '';
}
}
}
}
if (! empty($types)) {
$meta['custom_default_types'] = $types;
}
if (! empty($defaults)) {
$meta['custom_defaults'] = $defaults;
}
if ($meta !== ($template->meta ?? [])) {
$template->meta = $meta;
}
} catch (\Throwable $e) {
// ignore meta typing/defaults failures
}
$template->updated_by = Auth::id();
$template->save();
$count = is_array($tokens) ? count($tokens) : 0;
return back()->with('success', "Tokens posodobljeni ({$count} najdenih).");
}
public function store(StoreDocumentTemplateRequest $request) public function store(StoreDocumentTemplateRequest $request)
{ {
$this->ensurePermission(); $this->ensurePermission();
@@ -146,7 +240,7 @@ public function store(StoreDocumentTemplateRequest $request)
$hash = hash_file('sha256', $file->getRealPath()); $hash = hash_file('sha256', $file->getRealPath());
$path = $file->store("document-templates/{$slug}/v{$nextVersion}", 'public'); $path = $file->store("document-templates/{$slug}/v{$nextVersion}", 'public');
// Scan tokens from uploaded DOCX (best effort) // Scan tokens from uploaded DOCX (best effort) normalize XML to collapse Word run boundaries
$tokens = []; $tokens = [];
try { try {
/** @var TokenScanner $scanner */ /** @var TokenScanner $scanner */
@@ -155,10 +249,31 @@ public function store(StoreDocumentTemplateRequest $request)
$tmp = tempnam(sys_get_temp_dir(), 'tmpl'); $tmp = tempnam(sys_get_temp_dir(), 'tmpl');
copy($file->getRealPath(), $tmp); copy($file->getRealPath(), $tmp);
if ($zip->open($tmp) === true) { if ($zip->open($tmp) === true) {
$xml = $zip->getFromName('word/document.xml'); // Collect main document and header/footer parts
if ($xml !== false) { $parts = [];
$tokens = $scanner->scan($xml); for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$name = $stat['name'] ?? '';
if (preg_match('#^word\/(document|header\d+|footer\d+)\.xml$#i', $name)) {
$parts[] = $name;
}
} }
if (empty($parts)) {
$parts = ['word/document.xml'];
}
$found = [];
foreach ($parts as $name) {
$xml = $zip->getFromName($name);
if ($xml === false) {
continue;
}
$norm = self::normalizeDocxXmlTokens($xml);
$det = $scanner->scan($norm);
if (! empty($det)) {
$found = array_merge($found, $det);
}
}
$tokens = array_values(array_unique($found));
$zip->close(); $zip->close();
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -218,6 +333,36 @@ public function store(StoreDocumentTemplateRequest $request)
if (Schema::hasColumn('document_templates', 'tokens')) { if (Schema::hasColumn('document_templates', 'tokens')) {
$payload['tokens'] = $tokens; $payload['tokens'] = $tokens;
} }
// Auto-add default string types for any detected custom.* tokens
try {
$meta = isset($payload['meta']) && is_array($payload['meta']) ? $payload['meta'] : [];
$types = isset($meta['custom_default_types']) && is_array($meta['custom_default_types']) ? $meta['custom_default_types'] : [];
$defaults = isset($meta['custom_defaults']) && is_array($meta['custom_defaults']) ? $meta['custom_defaults'] : [];
foreach (($tokens ?? []) as $tok) {
if (is_string($tok) && str_starts_with($tok, 'custom.')) {
$key = substr($tok, 7);
if ($key !== '') {
if (! array_key_exists($key, $types)) {
$types[$key] = 'string';
}
if (! array_key_exists($key, $defaults)) {
$defaults[$key] = '';
}
}
}
}
if (! empty($types)) {
$meta['custom_default_types'] = $types;
}
if (! empty($defaults)) {
$meta['custom_defaults'] = $defaults;
}
if ($meta !== ($payload['meta'] ?? [])) {
$payload['meta'] = $meta;
}
} catch (\Throwable $e) {
// ignore meta typing/defaults failures
}
$template = DocumentTemplate::create($payload); $template = DocumentTemplate::create($payload);
return redirect()->back()->with('success', 'Predloga uspešno shranjena. (v'.$template->version.')')->with('template_id', $template->id); return redirect()->back()->with('success', 'Predloga uspešno shranjena. (v'.$template->version.')')->with('template_id', $template->id);
@@ -229,4 +374,29 @@ private function ensurePermission(): void
abort(403); abort(403);
} }
} }
/**
* Collapse common Word run boundaries and proofing spans so tokens like {{client.person.full_name}}
* appear contiguous in XML for scanning.
*/
private static function normalizeDocxXmlTokens(string $xml): string
{
// Remove proofing error markers
$xml = preg_replace('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
// Iteratively collapse boundaries between text runs, even if w:rPr is present
$patterns = [
'#</w:t>\s*</w:r>\s*(?:<w:proofErr[^>]*/>\s*)*(?:<w:r[^>]*>\s*(?:<w:rPr>.*?</w:rPr>\s*)*)?<w:t[^>]*>#is',
];
$prev = null;
while ($prev !== $xml) {
$prev = $xml;
foreach ($patterns as $pat) {
$xml = preg_replace($pat, '', $xml) ?? $xml;
}
}
// Remove zero-width and soft hyphen characters
$xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml);
return $xml;
}
} }
@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\EmailLog;
use App\Models\EmailTemplate;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmailLogController extends Controller
{
use AuthorizesRequests;
public function index(Request $request): Response
{
$this->authorize('viewAny', EmailTemplate::class); // reuse same permission gate for admin area
$query = EmailLog::query()
->with(['template:id,name'])
->orderByDesc('created_at');
$status = trim((string) $request->input('status', ''));
if ($status !== '') {
$query->where('status', $status);
}
if ($email = trim((string) $request->input('to'))) {
$query->where('to_email', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], $email).'%');
}
if ($subject = trim((string) $request->input('subject'))) {
$query->where('subject', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], $subject).'%');
}
if ($templateId = (int) $request->input('template_id')) {
$query->where('template_id', $templateId);
}
if ($from = $request->date('date_from')) {
$query->whereDate('created_at', '>=', $from);
}
if ($to = $request->date('date_to')) {
$query->whereDate('created_at', '<=', $to);
}
$logs = $query->paginate(20)->withQueryString();
$templates = EmailTemplate::query()->orderBy('name')->get(['id', 'name']);
return Inertia::render('Admin/EmailLogs/Index', [
'logs' => $logs,
'filters' => [
'status' => $status,
'to' => $email ?? '',
'subject' => $subject ?? '',
'template_id' => $templateId ?: null,
'date_from' => $request->input('date_from'),
'date_to' => $request->input('date_to'),
],
'templates' => $templates,
]);
}
public function show(EmailLog $emailLog): Response
{
$this->authorize('viewAny', EmailTemplate::class);
$emailLog->load(['template:id,name', 'body']);
return Inertia::render('Admin/EmailLogs/Show', [
'log' => $emailLog,
]);
}
public function body(EmailLog $emailLog): JsonResponse
{
$this->authorize('viewAny', EmailTemplate::class);
$emailLog->load('body');
return response()->json([
'html' => $emailLog->body?->body_html ?? '',
]);
}
}
@@ -0,0 +1,924 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEmailTemplateRequest;
use App\Jobs\SendEmailTemplateJob;
use App\Models\Activity;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Document;
use App\Models\EmailLog;
use App\Models\EmailLogStatus;
use App\Models\EmailTemplate;
use App\Models\MailProfile;
use App\Services\EmailTemplateRenderer;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\Mime\Email;
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
class EmailTemplateController extends Controller
{
use AuthorizesRequests;
public function update(\App\Http\Requests\UpdateEmailTemplateRequest $request, EmailTemplate $emailTemplate)
{
$this->authorize('update', $emailTemplate);
$data = $request->validated();
$emailTemplate->fill($data)->save();
// Move any tmp images referenced in HTML into permanent storage and attach as documents
$this->adoptTmpImages($emailTemplate);
return redirect()->route('admin.email-templates.edit', $emailTemplate)->with('success', 'Template updated');
}
use AuthorizesRequests;
public function index(): Response
{
$this->authorize('viewAny', EmailTemplate::class);
return Inertia::render('Admin/EmailTemplates/Index', [
'templates' => EmailTemplate::orderBy('name')->get(),
]);
}
public function create(): Response
{
$this->authorize('create', EmailTemplate::class);
$actions = \App\Models\Action::query()
->with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/EmailTemplates/Edit', [
'template' => null,
'actions' => $actions,
]);
}
public function store(StoreEmailTemplateRequest $request)
{
$data = $request->validated();
$tpl = EmailTemplate::create($data);
// Move any tmp images referenced in HTML into permanent storage and attach as documents
$this->adoptTmpImages($tpl);
return redirect()->route('admin.email-templates.edit', $tpl)->with('success', 'Template created');
}
/**
* Render a quick preview of the email template with the provided context.
* Does not persist any changes or inline CSS; intended for fast editor feedback.
*/
public function preview(Request $request, EmailTemplate $emailTemplate): JsonResponse
{
$this->authorize('view', $emailTemplate);
$renderer = app(EmailTemplateRenderer::class);
$subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template);
$html = (string) ($request->input('html') ?? $emailTemplate->html_template);
$text = (string) ($request->input('text') ?? $emailTemplate->text_template);
// Do not persist tmp images for preview, but allow showing them if already accessible
// Optionally repair missing img src and attach from template documents for a better preview
if (! empty($html)) {
$html = $this->repairImgWithoutSrc($html);
$html = $this->attachSrcFromTemplateDocuments($emailTemplate, $html);
}
// Context resolution (shared logic with renderFinalHtml)
$ctx = [];
if ($id = $request->integer('activity_id')) {
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
if ($activity) {
$ctx['activity'] = $activity;
// Derive base entities from activity when not explicitly provided
if ($activity->contract && ! isset($ctx['contract'])) {
$ctx['contract'] = $activity->contract;
}
if ($activity->clientCase && ! isset($ctx['client_case'])) {
$ctx['client_case'] = $activity->clientCase;
}
if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) {
$ctx['client'] = $ctx['client_case']->client;
$ctx['person'] = optional($ctx['client'])->person;
}
}
}
if ($id = $request->integer('contract_id')) {
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
if ($contract) {
$ctx['contract'] = $contract;
if ($contract->clientCase) {
$ctx['client_case'] = $contract->clientCase;
if ($contract->clientCase->client) {
$ctx['client'] = $contract->clientCase->client;
$ctx['person'] = optional($contract->clientCase->client)->person;
}
}
}
}
if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) {
$case = ClientCase::query()->with(['client.person'])->find($id);
if ($case) {
$ctx['client_case'] = $case;
if ($case->client) {
$ctx['client'] = $case->client;
$ctx['person'] = optional($case->client)->person;
}
}
}
if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) {
$client = Client::query()->with(['person'])->find($id);
if ($client) {
$ctx['client'] = $client;
$ctx['person'] = optional($client)->person;
}
}
$ctx['extra'] = (array) $request->input('extra', []);
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
$rendered = $renderer->render([
'subject' => $subject,
'html' => $html,
'text' => $text,
], $ctx);
return response()->json([
'subject' => $rendered['subject'] ?? $subject,
'html' => (string) ($rendered['html'] ?? $html ?? ''),
'text' => (string) ($rendered['text'] ?? $text ?? ''),
]);
}
public function edit(EmailTemplate $emailTemplate): Response
{
$this->authorize('update', $emailTemplate);
$emailTemplate->load(['documents' => function ($q) {
$q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']);
}]);
$actions = \App\Models\Action::query()
->with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/EmailTemplates/Edit', [
'template' => $emailTemplate,
'actions' => $actions,
]);
}
public function sendTest(Request $request, EmailTemplate $emailTemplate)
{
$this->authorize('send', $emailTemplate);
$renderer = app(EmailTemplateRenderer::class);
$subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template);
$html = (string) ($request->input('html') ?? $emailTemplate->html_template);
$text = (string) ($request->input('text') ?? $emailTemplate->text_template);
// Adopt tmp images (tmp/email-images) so test email can display images; also persist
$html = $this->adoptTmpImagesInHtml($emailTemplate, $html, true);
// Context resolution
$ctx = [];
if ($id = $request->integer('activity_id')) {
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
if ($activity) {
$ctx['activity'] = $activity;
if ($activity->contract && ! isset($ctx['contract'])) {
$ctx['contract'] = $activity->contract;
}
if ($activity->clientCase && ! isset($ctx['client_case'])) {
$ctx['client_case'] = $activity->clientCase;
}
if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) {
$ctx['client'] = $ctx['client_case']->client;
$ctx['person'] = optional($ctx['client'])->person;
}
}
}
if ($id = $request->integer('contract_id')) {
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
if ($contract) {
$ctx['contract'] = $contract;
if ($contract->clientCase) {
$ctx['client_case'] = $contract->clientCase;
if ($contract->clientCase->client) {
$ctx['client'] = $contract->clientCase->client;
$ctx['person'] = optional($contract->clientCase->client)->person;
}
}
}
}
if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) {
$case = ClientCase::query()->with(['client.person'])->find($id);
if ($case) {
$ctx['client_case'] = $case;
if ($case->client) {
$ctx['client'] = $case->client;
$ctx['person'] = optional($case->client)->person;
}
}
}
if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) {
$client = Client::query()->with(['person'])->find($id);
if ($client) {
$ctx['client'] = $client;
$ctx['person'] = optional($client)->person;
}
}
$ctx['extra'] = (array) $request->input('extra', []);
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
// Render preview values; we store a minimal snapshot on the log
$rendered = $renderer->render([
'subject' => $subject,
'html' => $html,
'text' => $text,
], $ctx);
$to = (string) $request->input('to');
if ($to === '' || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
return back()->with('error', 'Invalid target email');
}
// Prepare EmailLog record with queued status
$log = new EmailLog;
$log->fill([
'uuid' => (string) \Str::uuid(),
'template_id' => $emailTemplate->id,
'to_email' => $to,
'to_name' => null,
'subject' => (string) ($rendered['subject'] ?? $subject ?? ''),
'body_html_hash' => $rendered['html'] ? hash('sha256', $rendered['html']) : null,
'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null,
'embed_mode' => (string) $request->input('embed', 'base64'),
'status' => EmailLogStatus::Queued,
'queued_at' => now(),
'client_id' => $ctx['client']->id ?? null,
'client_case_id' => $ctx['client_case']->id ?? null,
'contract_id' => $ctx['contract']->id ?? null,
'extra_context' => $ctx['extra'] ?? null,
'ip' => $request->ip(),
]);
$log->save();
// Store bodies in companion table (optional, enabled here)
$log->body()->create([
'body_html' => (string) ($rendered['html'] ?? ''),
'body_text' => (string) ($rendered['text'] ?? ''),
'inline_css' => true,
]);
// Dispatch the queued job
dispatch(new SendEmailTemplateJob($log->id));
return back()->with('success', 'Test email queued for '.$to);
}
/**
* Render the final HTML exactly as it will be sent (repair <img>, attach from docs,
* inline images to base64, inline CSS). Does not persist any changes or send email.
*/
public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
{
$this->authorize('view', $emailTemplate);
$renderer = app(EmailTemplateRenderer::class);
$subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template);
$html = (string) ($request->input('html') ?? $emailTemplate->html_template);
$text = (string) ($request->input('text') ?? $emailTemplate->text_template);
// Do not persist tmp images, but allow previewing with them present
$html = $this->adoptTmpImagesInHtml($emailTemplate, $html, false);
// Context resolution (same as sendTest)
$ctx = [];
if ($id = $request->integer('activity_id')) {
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
if ($activity) {
$ctx['activity'] = $activity;
if ($activity->contract && ! isset($ctx['contract'])) {
$ctx['contract'] = $activity->contract;
}
if ($activity->clientCase && ! isset($ctx['client_case'])) {
$ctx['client_case'] = $activity->clientCase;
}
if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) {
$ctx['client'] = $ctx['client_case']->client;
$ctx['person'] = optional($ctx['client'])->person;
}
}
}
if ($id = $request->integer('contract_id')) {
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
if ($contract) {
$ctx['contract'] = $contract;
if ($contract->clientCase) {
$ctx['client_case'] = $contract->clientCase;
if ($contract->clientCase->client) {
$ctx['client'] = $contract->clientCase->client;
$ctx['person'] = optional($contract->clientCase->client)->person;
}
}
}
}
if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) {
$case = ClientCase::query()->with(['client.person'])->find($id);
if ($case) {
$ctx['client_case'] = $case;
if ($case->client) {
$ctx['client'] = $case->client;
$ctx['person'] = optional($case->client)->person;
}
}
}
if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) {
$client = Client::query()->with(['person'])->find($id);
if ($client) {
$ctx['client'] = $client;
$ctx['person'] = optional($client)->person;
}
}
$ctx['extra'] = (array) $request->input('extra', []);
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
$rendered = $renderer->render([
'subject' => $subject,
'html' => $html,
'text' => $text,
], $ctx);
$attachments = [];
if (! empty($rendered['html'])) {
$rendered['html'] = $this->repairImgWithoutSrc($rendered['html']);
$rendered['html'] = $this->attachSrcFromTemplateDocuments($emailTemplate, $rendered['html']);
$embed = (string) $request->input('embed', 'base64');
if ($embed === 'base64') {
try {
$imageInliner = app(\App\Services\EmailImageInliner::class);
$rendered['html'] = $imageInliner->inline($rendered['html']);
} catch (\Throwable $e) {
}
} else {
$rendered['html'] = $this->absolutizeStorageUrls($request, $rendered['html']);
}
try {
$inliner = new CssToInlineStyles;
$rendered['html'] = $inliner->convert($rendered['html']);
} catch (\Throwable $e) {
}
}
return response()->json([
'subject' => $rendered['subject'] ?? $subject,
'html' => $rendered['html'] ?? '',
'text' => $rendered['text'] ?? ($text ?? ''),
'attachments' => $attachments,
]);
}
/**
* Convert any <img src="/storage/..."> (or absolute URLs whose path is /storage/...) to
* absolute URLs using the current request scheme+host, so email clients like Gmail can fetch
* them through their proxy reliably.
*/
protected function absolutizeStorageUrls(Request $request, string $html): string
{
if ($html === '' || stripos($html, '<img') === false) {
return $html;
}
$base = (string) (config('app.asset_url') ?: config('app.url'));
$host = $base !== '' ? rtrim($base, '/') : $request->getSchemeAndHttpHost();
return preg_replace_callback('#<img([^>]+)src=["\']([^"\']+)["\']([^>]*)>#i', function (array $m) use ($host) {
$before = $m[1] ?? '';
$src = $m[2] ?? '';
$after = $m[3] ?? '';
$path = $src;
if (preg_match('#^https?://#i', $src)) {
$parts = parse_url($src);
$path = $parts['path'] ?? '';
if (! preg_match('#^/?storage/#i', (string) $path)) {
return $m[0];
}
} else {
if (! preg_match('#^/?storage/#i', (string) $path)) {
return $m[0];
}
}
$rel = '/'.ltrim(preg_replace('#^/?storage/#i', 'storage/', (string) $path), '/');
$abs = rtrim($host, '/').$rel;
return '<img'.$before.'src="'.$abs.'"'.$after.'>';
}, $html);
}
/**
* Fix patterns where an <img ...> tag lacks a src attribute but is immediately followed by a URL.
* Example to fix:
* <img alt="Logo">\nhttps://domain.tld/storage/email-images/foo.png
* becomes:
* <img alt="Logo" src="https://domain.tld/storage/email-images/foo.png">
* The trailing URL text is removed.
*/
protected function repairImgWithoutSrc(string $html): string
{
if ($html === '' || stripos($html, '<img') === false) {
return $html;
}
// Helper to set src on an <img> when not present and keep the in-between content
$setSrc = function (array $m): string {
$attrs = $m[1] ?? '';
$between = $m[2] ?? '';
$url = $m[3] ?? '';
if (preg_match('#\bsrc\s*=#i', $attrs)) {
return $m[0];
}
return '<img'.$attrs.' src="'.$url.'">'.$between;
};
// Up to 700 chars of any content (non-greedy) between tag and URL
$gap = '(.{0,700}?)';
$urlAbs = '(https?://[^\s<>"\']+/storage/[^\s<>"\']+)';
$urlRel = '(/storage/[^\s<>"\']+)';
// Case 1: Plain text URL after <img>
$html = preg_replace_callback('#<img([^>]*)>'.$gap.$urlAbs.'#is', $setSrc, $html);
$html = preg_replace_callback('#<img([^>]*)>'.$gap.$urlRel.'#is', $setSrc, $html);
// Case 2: Linked URL after <img> (keep the anchor text, consume the URL into src)
$setSrcAnchor = function (array $m): string {
$attrs = $m[1] ?? '';
$between = $m[2] ?? '';
$url = $m[3] ?? '';
$anchor = $m[4] ?? '';
if (preg_match('#\bsrc\s*=#i', $attrs)) {
return $m[0];
}
// Keep the anchor but its href stays as-is; we only set img src
return '<img'.$attrs.' src="'.$url.'">'.$between.$anchor;
};
$html = preg_replace_callback('#<img([^>]*)>'.$gap.$urlAbs.'(\s*<a[^>]+href=["\'][^"\']+["\'][^>]*>.*?</a>)#is', $setSrcAnchor, $html);
$html = preg_replace_callback('#<img([^>]*)>'.$gap.$urlRel.'(\s*<a[^>]+href=["\'][^"\']+["\'][^>]*>.*?</a>)#is', $setSrcAnchor, $html);
// Fallback: if a single image is missing src and there is a single /storage URL anywhere, attach it
if (preg_match_all('#<img(?![^>]*\bsrc\s*=)[^>]*>#i', $html, $missingImgs) === 1) {
if (count($missingImgs[0]) === 1) {
if (preg_match_all('#(?:https?://[^\s<>"\']+)?/storage/[^\s<>"\']+#i', $html, $urls) === 1 && count($urls[0]) === 1) {
$onlyUrl = $urls[0][0];
$html = preg_replace('#<img((?![^>]*\bsrc\s*=)[^>]*)>#i', '<img$1 src="'.$onlyUrl.'">', $html, 1);
}
}
}
return $html;
}
/**
* As a conservative fallback, populate missing <img> src attributes using this template's
* attached image Documents. We try to match by the alt attribute first (e.g., alt="Logo"
* will match a document named "logo.*"); if there is only one image document, we will use it.
*/
protected function attachSrcFromTemplateDocuments(EmailTemplate $tpl, string $html): string
{
if ($html === '' || stripos($html, '<img') === false) {
return $html;
}
// Collect candidate image docs from relation if loaded, otherwise query
$docs = $tpl->getRelationValue('documents');
if ($docs === null) {
$docs = $tpl->documents()->get(['id', 'name', 'path', 'file_name', 'original_name', 'mime_type']);
}
$imageDocs = collect($docs ?: [])->filter(function ($d) {
$mime = strtolower((string) ($d->mime_type ?? ''));
return $mime === '' || str_starts_with($mime, 'image/');
})->values();
if ($imageDocs->isEmpty()) {
return $html;
}
// Build lookups by basename without extension
$byStem = [];
foreach ($imageDocs as $d) {
$base = pathinfo($d->file_name ?: ($d->name ?: ($d->original_name ?: basename((string) $d->path))), PATHINFO_FILENAME);
if ($base) {
$byStem[strtolower($base)] = $d;
}
}
$callback = function (array $m) use (&$byStem, $imageDocs) {
$attrs = $m[1] ?? '';
if (preg_match('#\bsrc\s*=#i', $attrs)) {
return $m[0];
}
$alt = null;
if (preg_match('#\balt\s*=\s*(?:"([^"]*)"|\'([^\']*)\')#i', $attrs, $am)) {
$alt = trim(html_entity_decode($am[1] !== '' ? $am[1] : ($am[2] ?? ''), ENT_QUOTES | ENT_HTML5));
}
$chosen = null;
if ($alt) {
$key = strtolower(preg_replace('#[^a-z0-9]+#i', '', $alt));
// try exact stem
if (isset($byStem[strtolower($alt)])) {
$chosen = $byStem[strtolower($alt)];
}
if (! $chosen) {
// try relaxed: any stem containing the alt
foreach ($byStem as $stem => $doc) {
$relaxedStem = preg_replace('#[^a-z0-9]+#i', '', (string) $stem);
if ($relaxedStem !== '' && str_contains($relaxedStem, $key)) {
$chosen = $doc;
break;
}
}
}
}
if (! $chosen && method_exists($imageDocs, 'count') && $imageDocs->count() === 1) {
$chosen = $imageDocs->first();
}
if (! $chosen) {
return $m[0];
}
$url = '/storage/'.ltrim((string) $chosen->path, '/');
return '<img'.$attrs.' src="'.$url.'">';
};
$html = preg_replace_callback('#<img([^>]*)>#i', $callback, $html);
return $html;
}
/**
* Upload an image for use in email templates. Stores to a temporary folder first and returns a public URL.
*/
public function uploadImage(Request $request)
{
$this->authorize('create', EmailTemplate::class);
$validated = $request->validate([
'file' => ['required', 'image', 'max:5120'], // 5MB
]);
/** @var \Illuminate\Http\UploadedFile $file */
$file = $validated['file'];
// store into tmp first; move on save
$path = $file->store('tmp/email-images', 'public');
// Return a relative URL to avoid mismatched host/ports in dev
$url = '/storage/'.$path;
return response()->json([
'url' => $url,
'path' => $path,
'tmp' => true,
]);
}
/**
* Replace an image referenced by the template, updating the existing Document row if found
* (and deleting the old file), or creating a new Document if none exists. Returns the new URL.
*/
public function replaceImage(Request $request, EmailTemplate $emailTemplate)
{
$this->authorize('update', $emailTemplate);
$validated = $request->validate([
'file' => ['required', 'image', 'max:5120'],
'current_src' => ['nullable', 'string'],
]);
/** @var \Illuminate\Http\UploadedFile $file */
$file = $validated['file'];
$currentSrc = (string) ($validated['current_src'] ?? '');
// Normalize current src to a public disk path when possible
$currentPath = null;
if ($currentSrc !== '') {
$parsed = parse_url($currentSrc);
$path = $parsed['path'] ?? $currentSrc;
// Accept /storage/... or raw path; strip leading storage/
if (preg_match('#/storage/(.+)#i', $path, $m)) {
$path = $m[1];
}
$path = ltrim(preg_replace('#^storage/#', '', $path), '/');
if ($path !== '') {
$currentPath = $path;
}
}
// Find existing document for this template matching the path
$doc = null;
if ($currentPath) {
$doc = $emailTemplate->documents()->where('path', $currentPath)->first();
}
// Store the new file
$ext = $file->getClientOriginalExtension();
$nameBase = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) ?: 'image';
$dest = 'email-images/'.$nameBase.'-'.Str::uuid().($ext ? ('.'.$ext) : '');
Storage::disk('public')->put($dest, File::get($file->getRealPath()));
// Delete old file if we will update an existing document
if ($doc && $doc->path && Storage::disk('public')->exists($doc->path)) {
try {
Storage::disk('public')->delete($doc->path);
} catch (\Throwable $e) {
// ignore
}
}
$full = storage_path('app/public/'.$dest);
try {
$mime = File::exists($full) ? File::mimeType($full) : null;
} catch (\Throwable $e) {
$mime = null;
}
try {
$size = Storage::disk('public')->size($dest);
} catch (\Throwable $e) {
$size = null;
}
if ($doc) {
$doc->forceFill([
'name' => basename($dest),
'path' => $dest,
'file_name' => basename($dest),
'original_name' => $file->getClientOriginalName(),
'extension' => $ext ?: null,
'mime_type' => $mime,
'size' => $size,
])->save();
} else {
$doc = $emailTemplate->documents()->create([
'name' => basename($dest),
'description' => null,
'user_id' => optional(auth()->user())->id,
'disk' => 'public',
'path' => $dest,
'file_name' => basename($dest),
'original_name' => $file->getClientOriginalName(),
'extension' => $ext ?: null,
'mime_type' => $mime,
'size' => $size,
'is_public' => true,
]);
}
return response()->json([
'url' => '/storage/'.$dest,
'path' => $dest,
'document_id' => $doc->id,
'replaced' => (bool) $currentPath,
]);
}
/**
* Delete an attached image Document from the given email template.
*/
public function deleteImage(Request $request, EmailTemplate $emailTemplate, Document $document)
{
$this->authorize('update', $emailTemplate);
// Ensure the document belongs to this template (polymorphic relation)
if ((int) $document->documentable_id !== (int) $emailTemplate->id || $document->documentable_type !== EmailTemplate::class) {
return response()->json(['message' => 'Document does not belong to this template.'], 422);
}
try {
// Force delete to remove underlying file as well (Document model handles file deletion on force delete)
$document->forceDelete();
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to delete image: '.$e->getMessage()], 500);
}
return response()->json(['deleted' => true]);
}
/**
* Scan HTML for images stored in /storage/tmp/email-images and move them into a permanent
* location under /storage/email-images, create Document records and update the HTML.
*/
protected function adoptTmpImages(EmailTemplate $tpl): void
{
$html = (string) ($tpl->html_template ?? '');
if ($html === '' || stripos($html, 'tmp/email-images/') === false) {
return;
}
// Match any tmp paths inside src attributes, accepting absolute or relative URLs
$paths = [];
$matches = [];
if (preg_match_all('#/storage/tmp/email-images/[^"\']+#i', $html, $matches)) {
$paths = array_merge($paths, $matches[0]);
}
if (preg_match_all('#tmp/email-images/[^"\']+#i', $html, $matches)) {
$paths = array_merge($paths, $matches[0]);
}
$paths = array_values(array_unique($paths));
if (empty($paths)) {
return;
}
foreach ($paths as $tmpRel) {
// Normalize path (strip any leading storage/)
// Normalize to disk-relative path
$tmpRel = ltrim(preg_replace('#^/?storage/#i', '', $tmpRel), '/');
if (! Storage::disk('public')->exists($tmpRel)) {
continue;
}
$ext = pathinfo($tmpRel, PATHINFO_EXTENSION);
$base = pathinfo($tmpRel, PATHINFO_FILENAME);
$candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : '');
// Ensure dest doesn't exist
while (Storage::disk('public')->exists($candidate)) {
$candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : '');
}
// Move the file
Storage::disk('public')->move($tmpRel, $candidate);
// Create Document record
try {
$full = storage_path('app/public/'.$candidate);
$mime = File::exists($full) ? File::mimeType($full) : null;
} catch (\Throwable $e) {
$mime = null;
}
try {
$size = Storage::disk('public')->size($candidate);
} catch (\Throwable $e) {
$size = null;
}
$tpl->documents()->create([
'name' => basename($candidate),
'description' => null,
'user_id' => optional(auth()->user())->id,
'disk' => 'public',
'path' => $candidate,
'file_name' => basename($candidate),
'original_name' => basename($candidate),
'extension' => $ext ?: null,
'mime_type' => $mime,
'size' => $size,
'is_public' => true,
]);
// Update HTML to reference the new permanent path (use relative /storage URL)
$to = '/storage/'.$candidate;
$from = ['/storage/'.$tmpRel, $tmpRel];
$html = str_replace($from, $to, $html);
// Also replace absolute URL variants like https://domain/storage/<path>
$pattern = '#https?://[^"\']+/storage/'.preg_quote($tmpRel, '#').'#i';
$html = preg_replace($pattern, $to, $html);
}
if ($html !== (string) ($tpl->html_template ?? '')) {
$tpl->forceFill(['html_template' => $html])->save();
}
}
/**
* Move any tmp images present in the provided HTML into permanent storage, attach documents,
* and return the updated HTML. Optionally persist the template's HTML.
*/
protected function adoptTmpImagesInHtml(EmailTemplate $tpl, string $html, bool $persistTemplate = false): string
{
if ($html === '' || stripos($html, 'tmp/email-images/') === false) {
return $html;
}
$paths = [];
$matches = [];
if (preg_match_all('#/storage/tmp/email-images/[^"\']+#i', $html, $matches)) {
$paths = array_merge($paths, $matches[0]);
}
if (preg_match_all('#tmp/email-images/[^"\']+#i', $html, $matches)) {
$paths = array_merge($paths, $matches[0]);
}
$paths = array_values(array_unique($paths));
if (empty($paths)) {
return $html;
}
foreach ($paths as $tmpRel) {
$tmpRel = ltrim(preg_replace('#^/?storage/#i', '', $tmpRel), '/');
if (! Storage::disk('public')->exists($tmpRel)) {
continue;
}
$ext = pathinfo($tmpRel, PATHINFO_EXTENSION);
$base = pathinfo($tmpRel, PATHINFO_FILENAME);
$candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : '');
while (Storage::disk('public')->exists($candidate)) {
$candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : '');
}
Storage::disk('public')->move($tmpRel, $candidate);
try {
$mime = File::exists(storage_path('app/public/'.$candidate)) ? File::mimeType(storage_path('app/public/'.$candidate)) : null;
} catch (\Throwable $e) {
$mime = null;
}
try {
$size = Storage::disk('public')->size($candidate);
} catch (\Throwable $e) {
$size = null;
}
$tpl->documents()->create([
'name' => basename($candidate),
'description' => null,
'user_id' => optional(auth()->user())->id,
'disk' => 'public',
'path' => $candidate,
'file_name' => basename($candidate),
'original_name' => basename($candidate),
'extension' => $ext ?: null,
'mime_type' => $mime,
'size' => $size,
'is_public' => true,
]);
$to = '/storage/'.$candidate;
$from = ['/storage/'.$tmpRel, $tmpRel];
$html = str_replace($from, $to, $html);
$pattern = '#https?://[^"\']+/storage/'.preg_quote($tmpRel, '#').'#i';
$html = preg_replace($pattern, $to, $html);
}
if ($persistTemplate && $tpl->exists) {
$tpl->forceFill(['html_template' => $html])->save();
}
return $html;
}
/**
* Small JSON endpoints to support cascading selects in editor preview.
*/
public function clients(Request $request)
{
$this->authorize('viewAny', EmailTemplate::class);
$items = Client::query()->with(['person'])->latest('id')->limit(50)->get();
return response()->json($items->map(fn ($c) => [
'id' => $c->id,
'label' => trim(($c->person->full_name ?? '').' #'.$c->id) ?: ('Client #'.$c->id),
]));
}
public function casesForClient(Request $request, Client $client)
{
$this->authorize('viewAny', EmailTemplate::class);
$items = ClientCase::query()
->with(['person'])
->where('client_id', $client->id)
->latest('id')
->limit(50)
->get();
return response()->json($items->map(function ($cs) {
$person = $cs->person->full_name ?? '';
$ref = $cs->reference ?? '';
$base = trim(($ref !== '' ? ($ref.' ') : '').'#'.$cs->id);
$label = trim(($person !== '' ? ($person.' — ') : '').$base);
return [
'id' => $cs->id,
'label' => $label !== '' ? $label : ('Case #'.$cs->id),
];
}));
}
public function contractsForCase(Request $request, ClientCase $clientCase)
{
$this->authorize('viewAny', EmailTemplate::class);
$items = Contract::query()->where('client_case_id', $clientCase->id)->latest('id')->limit(50)->get();
return response()->json($items->map(fn ($ct) => [
'id' => $ct->id,
'label' => trim(($ct->reference ?? '').' #'.$ct->id) ?: ('Contract #'.$ct->id),
]));
}
}
@@ -0,0 +1,170 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreMailProfileRequest;
use App\Http\Requests\UpdateMailProfileRequest;
use App\Models\MailProfile;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\Mailer\Mailer as SymfonyMailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
class MailProfileController extends Controller
{
use AuthorizesRequests;
public function index(): Response
{
$this->authorize('viewAny', MailProfile::class);
$profiles = MailProfile::query()
->orderBy('priority')
->orderBy('id')
->get([
'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', [
'profiles' => $profiles,
]);
}
public function store(StoreMailProfileRequest $request)
{
$data = $request->validated();
$profile = new MailProfile;
foreach ($data as $key => $val) {
if ($key === 'password') {
$profile->password = $val; // triggers mutator to encrypt
} else {
$profile->{$key} = $val;
}
}
$profile->save();
return back()->with('success', 'Mail profile created');
}
public function update(UpdateMailProfileRequest $request, MailProfile $mailProfile)
{
$data = $request->validated();
foreach ($data as $key => $val) {
if ($key === 'password') {
if ($val !== null && $val !== '') {
$mailProfile->password = $val;
}
} else {
$mailProfile->{$key} = $val;
}
}
$mailProfile->save();
return back()->with('success', 'Mail profile updated');
}
public function toggle(Request $request, MailProfile $mailProfile)
{
$this->authorize('update', $mailProfile);
$mailProfile->active = ! $mailProfile->active;
$mailProfile->save();
return back()->with('success', 'Status updated');
}
public function toggleAutoMailer(Request $request, MailProfile $mailProfile)
{
$this->authorize('update', $mailProfile);
$mailProfile->auto_mailer = ! $mailProfile->auto_mailer;
$mailProfile->save();
return back()->with('success', 'Auto-mailer updated');
}
public function test(Request $request, MailProfile $mailProfile)
{
$this->authorize('test', $mailProfile);
$mailProfile->forceFill([
'test_status' => 'queued',
'test_checked_at' => now(),
])->save();
\App\Jobs\TestMailProfileConnection::dispatch($mailProfile->id);
return back()->with('success', 'Test queued');
}
public function destroy(MailProfile $mailProfile)
{
$this->authorize('delete', $mailProfile);
$mailProfile->delete();
return back()->with('success', 'Mail profile deleted');
}
public function sendTest(Request $request, MailProfile $mailProfile)
{
$this->authorize('test', $mailProfile);
$to = (string) ($request->input('to') ?: $mailProfile->from_address);
if ($to === '' || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
return back()->with('error', 'Missing or invalid target email address');
}
// Build DSN for Symfony Mailer transport based on profile
$host = $mailProfile->host;
$port = (int) ($mailProfile->port ?: 587);
$encryption = $mailProfile->encryption ?: 'tls';
$username = $mailProfile->username ?: '';
$password = (string) ($mailProfile->decryptPassword() ?? '');
// Map encryption to Symfony DSN
$scheme = $encryption === 'ssl' ? 'smtps' : 'smtp';
$query = '';
if ($encryption === 'tls') {
$query = '?encryption=tls';
}
$dsn = sprintf('%s://%s:%s@%s:%d%s', $scheme, rawurlencode($username), rawurlencode($password), $host, $port, $query);
try {
$transport = Transport::fromDsn($dsn);
$mailer = new SymfonyMailer($transport);
$fromAddr = $mailProfile->from_address ?: $username;
$fromName = (string) ($mailProfile->from_name ?: (config('mail.from.name') ?? config('app.name') ?? ''));
$html = '<p>This is a <strong>test email</strong> from profile <code>'.e($mailProfile->name).'</code> at '.e(now()->toDateTimeString()).'.</p>';
$text = 'This is a test email from profile "'.$mailProfile->name.'" at '.now()->toDateTimeString().'.';
// Build email
$fromAddress = $fromName !== '' ? new Address($fromAddr, $fromName) : new Address($fromAddr);
$email = (new Email)
->from($fromAddress)
->to($to)
->subject('Test email - '.$mailProfile->name)
->text($text)
->html($html);
$mailer->send($email);
$mailProfile->forceFill([
'last_success_at' => now(),
'last_error_at' => null,
'last_error_message' => null,
])->save();
return back()->with('success', 'Test email sent to '.$to);
} catch (\Throwable $e) {
$mailProfile->forceFill([
'last_error_at' => now(),
'last_error_message' => $e->getMessage(),
])->save();
return back()->with('error', 'Failed to send test: '.$e->getMessage());
}
}
}
@@ -0,0 +1,877 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEmailPackageFromContractsRequest;
use App\Http\Requests\StorePackageFromContractsRequest;
use App\Http\Requests\StorePackageRequest;
use App\Jobs\PackageItemEmailJob;
use App\Jobs\PackageItemSmsJob;
use App\Models\Contract;
use App\Models\Package;
use App\Models\PackageItem;
use App\Models\SmsTemplate;
use App\Services\Contact\EmailSelector;
use App\Services\Contact\PhoneSelector;
use App\Services\Sms\SmsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class PackageController extends Controller
{
public function landing(): Response
{
return Inertia::render('Packages/Index');
}
public function smsIndex(Request $request): Response
{
$perPage = $request->input('per_page') ?? 25;
$packages = Package::query()
->where('type', Package::TYPE_SMS)
->latest('id')
->paginate($perPage);
return Inertia::render('Packages/Sms/Index', [
'packages' => $packages,
]);
}
public function emailIndex(Request $request): Response
{
$perPage = $request->input('per_page') ?? 25;
$packages = Package::query()
->where('type', Package::TYPE_EMAIL)
->latest('id')
->paginate($perPage);
return Inertia::render('Packages/Mail/Index', [
'packages' => $packages,
]);
}
public function smsCreate(Request $request): Response
{
// Minimal lookups for create form (active only)
$profiles = \App\Models\SmsProfile::query()
->where('active', true)
->orderBy('name')
->get(['id', 'name']);
$senders = \App\Models\SmsSender::query()
->where('active', true)
->orderBy('sname')
->get(['id', 'profile_id', 'sname', 'phone_number']);
$templates = \App\Models\SmsTemplate::query()
->orderBy('name')
->get(['id', 'name', 'content']);
$segments = \App\Models\Segment::query()
->where('active', true)
->where('exclude', false)
->orderBy('name')
->get(['id', 'name']);
// Provide a lightweight list of recent clients with person names for filtering
$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/Sms/Create', [
'profiles' => $profiles,
'senders' => $senders,
'templates' => $templates,
'segments' => $segments,
'clients' => $clients,
]);
}
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);
// Preload contracts/accounts for current page items to compute per-item previews
$contractIds = collect($items->items())
->map(fn ($it) => (array) ($it->target_json ?? []))
->map(fn ($t) => $t['contract_id'] ?? null)
->filter()
->unique()
->values();
$contracts = $contractIds->isNotEmpty()
? Contract::query()->with('account.type')->whereIn('id', $contractIds)->get()->keyBy('id')
: collect();
// Attach rendered_preview to each item
$collection = collect($items->items());
$collection = $collection->transform(function ($it) use ($sms, $contracts) {
$payload = (array) ($it->payload_json ?? []);
$tgt = (array) ($it->target_json ?? []);
$vars = (array) ($payload['variables'] ?? []);
if (! empty($tgt['contract_id']) && $contracts->has($tgt['contract_id'])) {
$c = $contracts->get($tgt['contract_id']);
$vars['contract'] = [
'id' => $c->id,
'uuid' => $c->uuid,
'reference' => $c->reference,
'start_date' => (string) ($c->start_date ?? ''),
'end_date' => (string) ($c->end_date ?? ''),
];
// Include contract.meta as flattened key-value pairs
if (is_array($c->meta) && ! empty($c->meta)) {
$vars['contract']['meta'] = $this->flattenMeta($c->meta);
}
if ($c->account) {
$initialRaw = (string) $c->account->initial_amount;
$balanceRaw = (string) $c->account->balance_amount;
$vars['account'] = [
'id' => $c->account->id,
'reference' => $c->account->reference,
// Use EU formatted values for SMS previews
'initial_amount' => $sms->formatAmountEu($initialRaw),
'balance_amount' => $sms->formatAmountEu($balanceRaw),
// Also expose raw values
'initial_amount_raw' => $initialRaw,
'balance_amount_raw' => $balanceRaw,
'type' => $c->account->type?->name,
];
}
}
// Prefer recorded message from result_json if available (sent items)
$result = (array) ($it->result_json ?? []);
$rendered = $result['message'] ?? null;
if (! $rendered) {
$body = isset($payload['body']) ? trim((string) $payload['body']) : '';
if ($body !== '') {
$rendered = $sms->renderContent($body, $vars);
} elseif (! empty($payload['template_id'])) {
$tpl = \App\Models\SmsTemplate::find((int) $payload['template_id']);
if ($tpl) {
$rendered = $sms->renderContent($tpl->content, $vars);
}
}
}
$it->rendered_preview = $rendered;
return $it;
});
// Replace paginator collection
if (method_exists($items, 'setCollection')) {
$items->setCollection($collection);
}
// Build a preview of message content from the first item (shared payload across package)
$preview = null;
$firstItem = $package->items()->oldest('id')->first();
if ($firstItem) {
$payload = (array) ($firstItem->payload_json ?? []);
$body = isset($payload['body']) ? trim((string) $payload['body']) : '';
// Enrich variables with contract/account for preview if available
$vars = (array) ($payload['variables'] ?? []);
$tgt = (array) ($firstItem->target_json ?? []);
if (! empty($tgt['contract_id'])) {
$c = Contract::query()->with('account.type')->find($tgt['contract_id']);
if ($c) {
$vars['contract'] = [
'id' => $c->id,
'uuid' => $c->uuid,
'reference' => $c->reference,
'start_date' => (string) ($c->start_date ?? ''),
'end_date' => (string) ($c->end_date ?? ''),
];
// Include contract.meta as flattened key-value pairs
if (is_array($c->meta) && ! empty($c->meta)) {
$vars['contract']['meta'] = $this->flattenMeta($c->meta);
}
if ($c->account) {
$initialRaw = (string) $c->account->initial_amount;
$balanceRaw = (string) $c->account->balance_amount;
$vars['account'] = [
'id' => $c->account->id,
'reference' => $c->account->reference,
'initial_amount' => $sms->formatAmountEu($initialRaw),
'balance_amount' => $sms->formatAmountEu($balanceRaw),
'initial_amount_raw' => $initialRaw,
'balance_amount_raw' => $balanceRaw,
'type' => $c->account->type?->name,
];
}
}
}
if ($body !== '') {
$preview = [
'source' => 'body',
'content' => $sms->renderContent($body, $vars),
];
} elseif (! empty($payload['template_id'])) {
/** @var SmsTemplate|null $tpl */
$tpl = SmsTemplate::find((int) $payload['template_id']);
if ($tpl) {
$content = $sms->renderContent($tpl->content, $vars);
$preview = [
'source' => 'template',
'template' => [
'id' => $tpl->id,
'name' => $tpl->name,
],
'content' => $content,
];
}
}
}
return Inertia::render('Packages/Sms/Show', [
'package' => $package,
'items' => $items,
'preview' => $preview,
]);
}
public function emailShow(Package $package): Response
{
$items = $package->items()->latest('id')->paginate(25);
return Inertia::render('Packages/Mail/Show', [
'package' => $package,
'items' => $items,
]);
}
public function store(StorePackageRequest $request): RedirectResponse
{
$data = $request->validated();
$package = Package::query()->create([
'uuid' => (string) Str::uuid(),
'type' => $data['type'],
'status' => Package::STATUS_DRAFT,
'name' => $data['name'] ?? null,
'description' => $data['description'] ?? null,
'meta' => $data['meta'] ?? [],
'created_by' => optional($request->user())->id,
]);
dd($data['items']);
$items = collect($data['items'])
->map(function (array $row) {
return new PackageItem([
'status' => 'queued',
'target_json' => [
'number' => (string) $row['number'],
'phone_id' => $row['phone_id'] ?? null,
],
'payload_json' => $row['payload'] ?? [],
]);
});
$package->items()->saveMany($items);
$package->total_items = $items->count();
$package->save();
return back()->with('success', 'Package created');
}
public function dispatch(Package $package): RedirectResponse
{
if (! in_array($package->status, [Package::STATUS_DRAFT, Package::STATUS_FAILED], true)) {
return back()->with('error', 'Package not in a dispatchable state.');
}
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) use ($package) {
if ($package->type === Package::TYPE_EMAIL) {
return new PackageItemEmailJob($item->id);
}
return new PackageItemSmsJob($item->id);
})->all();
if (empty($jobs)) {
return back()->with('error', 'No items to dispatch.');
}
$package->status = Package::STATUS_QUEUED;
$package->save();
Bus::batch($jobs)
->name('pkg:'.$package->id.' ('.$package->type.')')
->then(function () use ($package) {
// If finished counters not set by items (e.g., empty), finalize
$package->refresh();
if (($package->sent_count + $package->failed_count) >= $package->total_items) {
$finalStatus = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED;
$package->status = $finalStatus;
$package->finished_at = now();
$package->save();
} else {
$package->status = Package::STATUS_RUNNING;
$package->save();
}
})
->onQueue($package->type === Package::TYPE_EMAIL ? 'email' : 'sms')
->dispatch();
return back()->with('success', 'Package dispatched');
}
public function cancel(Package $package): RedirectResponse
{
$package->status = Package::STATUS_CANCELED;
$package->save();
return back()->with('success', 'Package canceled');
}
public function destroy(Package $package): RedirectResponse
{
// Allow deletion only for drafts (not yet dispatched)
if ($package->status !== Package::STATUS_DRAFT) {
return back()->with('error', 'Package not in a deletable state.');
}
// Remove items first to avoid FK issues
$package->items()->delete();
$package->delete();
return back()->with('success', 'Package deleted');
}
/**
* List contracts for a given segment and include selected phone per person.
*/
public function contracts(Request $request, PhoneSelector $selector): \Illuminate\Http\JsonResponse
{
$request->validate([
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'q' => ['nullable', 'string'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'only_mobile' => ['nullable', 'boolean'],
'only_validated' => ['nullable', 'boolean'],
'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.phones',
'clientCase.client.person',
'account',
'segments:id,name',
])
->select('contracts.*')
->latest('contracts.id');
// Optional segment filter
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 {
// 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'))) {
$query->where(function ($w) use ($q) {
$w->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);
}
// Date range filters for start_date
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);
}
// Date range filters for account.promise_date
$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);
}
});
}
// Optional phone filters
if ($request->boolean('only_mobile') || $request->boolean('only_validated')) {
$query->whereHas('clientCase.person.phones', function ($q) use ($request) {
if ($request->boolean('only_mobile')) {
$q->where('person_phones.phone_type', 'mobile');
}
if ($request->boolean('only_validated')) {
$q->where('person_phones.validated', true);
}
});
}
$contracts = $query->limit(500)->get();
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
$person = $contract->clientCase?->person;
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
$phone = $selected['phone'];
$clientPerson = $contract->clientCase?->client?->person;
$segment = collect($contract->segments)->last();
return [
'id' => $contract->id,
'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,
],
// Primer: the case person
'person' => [
'id' => $person?->id,
'uuid' => $person?->uuid,
'full_name' => $person?->full_name,
],
'segment' => $segment,
// Stranka: the client person
'client' => $clientPerson ? [
'id' => $contract->clientCase?->client?->id,
'uuid' => $contract->clientCase?->client?->uuid,
'name' => $clientPerson->full_name,
] : null,
'selected_phone' => $phone ? [
'id' => $phone->id,
'number' => $phone->nu,
'validated' => $phone->validated,
'type' => $phone->phone_type?->value,
'description' => $phone->description,
] : null,
'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'),
];
});
return response()->json([
'data' => $data,
]);
}
/**
* Create an SMS package from a list of contracts by selecting recipient phones.
*/
public function storeFromContracts(StorePackageFromContractsRequest $request, PhoneSelector $selector): RedirectResponse
{
$data = $request->validated();
// Load contracts with people, phones and account (for template placeholders)
$contracts = Contract::query()
->with(['clientCase.person.phones', 'account.type'])
->whereIn('id', $data['contract_ids'])
->get();
$items = [];
$seen = collect(); // de-dup by phone_id or number
$skipped = 0;
foreach ($contracts as $contract) {
$person = $contract->clientCase?->person;
if (! $person) {
$skipped++;
continue;
}
$selected = $selector->selectForPerson($person);
/** @var ?\App\Models\Person\PersonPhone $phone */
$phone = $selected['phone'];
if (! $phone) {
$skipped++;
continue;
}
$key = $phone->id ? 'id:'.$phone->id : 'num:'.$phone->nu;
/*if ($seen->contains($key)) {
// skip duplicates across multiple contracts/persons
$skipped++;
continue;
}*/
$seen->push($key);
$items[] = [
'number' => (string) $phone->nu,
'phone_id' => $phone->id,
'payload' => $data['payload'] ?? [],
// Keep context for variable rendering during send
'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' => $data['type'],
'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' => [
'number' => $row['number'],
'phone_id' => $row['phone_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', 'Package created from contracts');
}
/**
* List contracts with selected email per person (for email packages).
*/
public function contractsForEmail(Request $request, EmailSelector $selector): \Illuminate\Http\JsonResponse
{
$request->validate([
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'q' => ['nullable', 'string'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'only_verified' => ['nullable', 'boolean'],
'only_with_email' => ['nullable', 'boolean'],
'start_date_from' => ['nullable', 'date'],
'start_date_to' => ['nullable', 'date'],
'promise_date_from' => ['nullable', 'date'],
'promise_date_to' => ['nullable', 'date'],
]);
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
$query = Contract::query()
->with([
'clientCase.person.emails',
'clientCase.client.person',
'account',
'segments:id,name',
])
->select('contracts.*')
->latest('contracts.id');
if ($segmentId) {
$query->join('contract_segment', function ($j) use ($segmentId) {
$j->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.segment_id', '=', $segmentId)
->where('contract_segment.active', true);
});
} else {
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
->from('contract_segment')
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
->where('contract_segment.active', true)
->where('segments.exclude', false)
->whereColumn('contract_segment.contract_id', 'contracts.id')
);
}
if ($q = trim((string) $request->input('q'))) {
$query->where('contracts.reference', 'ILIKE', "%{$q}%");
}
if ($clientId = $request->integer('client_id')) {
$query->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
->where('client_cases.client_id', $clientId);
}
if ($startDateFrom = $request->input('start_date_from')) {
$query->where('contracts.start_date', '>=', $startDateFrom);
}
if ($startDateTo = $request->input('start_date_to')) {
$query->where('contracts.start_date', '<=', $startDateTo);
}
$promiseDateFrom = $request->input('promise_date_from');
$promiseDateTo = $request->input('promise_date_to');
if ($promiseDateFrom || $promiseDateTo) {
$query->whereHas('account', function ($q) use ($promiseDateFrom, $promiseDateTo) {
if ($promiseDateFrom) {
$q->where('promise_date', '>=', $promiseDateFrom);
}
if ($promiseDateTo) {
$q->where('promise_date', '<=', $promiseDateTo);
}
});
}
if ($request->boolean('only_verified')) {
$query->whereHas('clientCase.person.emails', function ($q) {
$q->where('is_active', true)->whereNotNull('verified_at');
});
}
if ($request->boolean('only_with_email')) {
$query->whereHas('clientCase.person.emails', function ($q) {
$q->where('is_active', true);
});
}
$contracts = $query->limit(500)->get();
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
$person = $contract->clientCase?->person;
$selected = $person ? $selector->selectForPerson($person) : ['email' => null, 'reason' => 'no_person'];
$email = $selected['email'];
$clientPerson = $contract->clientCase?->client?->person;
$segment = collect($contract->segments)->last();
return [
'id' => $contract->id,
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'start_date' => $contract->start_date,
'promise_date' => $contract->account?->promise_date,
'case' => [
'id' => $contract->clientCase?->id,
'uuid' => $contract->clientCase?->uuid,
],
'person' => [
'id' => $person?->id,
'uuid' => $person?->uuid,
'full_name' => $person?->full_name,
],
'segment' => $segment,
'client' => $clientPerson ? [
'id' => $contract->clientCase?->client?->id,
'uuid' => $contract->clientCase?->client?->uuid,
'name' => $clientPerson->full_name,
] : null,
'selected_email' => $email ? [
'id' => $email->id,
'value' => $email->value,
'is_primary' => $email->is_primary,
'verified' => $email->verified_at !== null,
'label' => $email->label,
] : null,
'no_email_reason' => $email ? null : ($selected['reason'] ?? 'unknown'),
];
});
return response()->json(['data' => $data]);
}
/**
* Create an email package from a list of contracts by selecting recipient emails.
*/
public function storeEmailFromContracts(StoreEmailPackageFromContractsRequest $request, EmailSelector $selector): RedirectResponse
{
$data = $request->validated();
$contracts = Contract::query()
->with(['clientCase.person', 'account.type'])
->whereIn('id', $data['contract_ids'])
->get();
$items = [];
$skipped = 0;
foreach ($contracts as $contract) {
$person = $contract->clientCase?->person;
if (! $person) {
$skipped++;
continue;
}
$selected = $selector->selectForPerson($person);
/** @var ?\App\Models\Email $email */
$email = $selected['email'];
if (! $email) {
$skipped++;
continue;
}
$items[] = [
'email' => $email->value,
'email_id' => $email->id,
'payload' => $data['payload'] ?? [],
'contract_id' => $contract->id,
'account_id' => $contract->account?->id,
];
}
if (empty($items)) {
return back()->with('error', 'No recipients found for selected contracts.');
}
$package = Package::query()->create([
'uuid' => (string) Str::uuid(),
'type' => Package::TYPE_EMAIL,
'status' => Package::STATUS_DRAFT,
'name' => $data['name'] ?? null,
'description' => $data['description'] ?? null,
'meta' => array_merge($data['meta'] ?? [], [
'source' => 'contracts',
'skipped' => $skipped,
]),
'created_by' => optional($request->user())->id,
]);
$packageItems = collect($items)->map(function (array $row) {
return new PackageItem([
'status' => 'queued',
'target_json' => [
'email' => $row['email'],
'email_id' => $row['email_id'],
'contract_id' => $row['contract_id'] ?? null,
'account_id' => $row['account_id'] ?? null,
],
'payload_json' => $row['payload'] ?? [],
]);
});
$package->items()->saveMany($packageItems);
$package->total_items = $packageItems->count();
$package->save();
return back()->with('success', 'Email package created from contracts');
}
/**
* Flatten nested meta structure into dot-notation key-value pairs.
* Extracts 'value' from objects with {title, value, type} structure.
* Also creates direct access aliases for nested fields (skipping numeric keys).
*/
private function flattenMeta(array $meta, string $prefix = ''): array
{
$result = [];
foreach ($meta as $key => $value) {
$newKey = $prefix === '' ? $key : "{$prefix}.{$key}";
if (is_array($value)) {
// Check if it's a structured meta entry with 'value' field
if (isset($value['value'])) {
$result[$newKey] = $value['value'];
// If parent key is numeric, also create direct alias without the number
if ($prefix !== '' && is_numeric($key)) {
$result[$key] = $value['value'];
}
} else {
// Recursively flatten nested arrays
$nested = $this->flattenMeta($value, $newKey);
$result = array_merge($result, $nested);
// If current key is numeric, also flatten without it for easier access
if (is_numeric($key)) {
$directNested = $this->flattenMeta($value, $prefix);
foreach ($directNested as $dk => $dv) {
// Only add if not already set (prefer first occurrence)
if (! isset($result[$dk])) {
$result[$dk] = $dv;
}
}
}
}
} else {
$result[$newKey] = $value;
}
}
return $result;
}
}
@@ -4,7 +4,9 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\StorePermissionRequest; use App\Http\Requests\StorePermissionRequest;
use App\Http\Requests\UpdatePermissionRequest;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Role;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -14,7 +16,7 @@ class PermissionController extends Controller
public function index(): Response public function index(): Response
{ {
$permissions = Permission::query() $permissions = Permission::query()
->select('id','name','slug','description','created_at') ->select('id', 'name', 'slug', 'description', 'created_at')
->orderBy('name') ->orderBy('name')
->get(); ->get();
@@ -22,15 +24,51 @@ public function index(): Response
'permissions' => $permissions, 'permissions' => $permissions,
]); ]);
} }
public function create(): Response public function create(): Response
{ {
return Inertia::render('Admin/Permissions/Create'); $roles = Role::orderBy('name')->get(['id', 'name', 'slug']);
return Inertia::render('Admin/Permissions/Create', [
'roles' => $roles,
]);
} }
public function store(StorePermissionRequest $request): RedirectResponse public function store(StorePermissionRequest $request): RedirectResponse
{ {
Permission::create($request->validated()); $data = $request->validated();
$roleIds = $data['roles'] ?? [];
unset($data['roles']);
return redirect()->route('admin.index')->with('success', 'Dovoljenje ustvarjeno.'); $permission = Permission::create($data);
if (! empty($roleIds)) {
$permission->roles()->sync($roleIds);
}
return redirect()->route('admin.permissions.index')->with('success', 'Dovoljenje ustvarjeno.');
}
public function edit(Permission $permission): Response
{
$roles = Role::orderBy('name')->get(['id', 'name', 'slug']);
$selected = $permission->roles()->pluck('roles.id');
return Inertia::render('Admin/Permissions/Edit', [
'permission' => $permission->only('id', 'name', 'slug', 'description'),
'roles' => $roles,
'selectedRoleIds' => $selected,
]);
}
public function update(UpdatePermissionRequest $request, Permission $permission): RedirectResponse
{
$data = $request->validated();
$roleIds = $data['roles'] ?? [];
unset($data['roles']);
$permission->update($data);
$permission->roles()->sync($roleIds);
return redirect()->route('admin.permissions.index')->with('success', 'Dovoljenje posodobljeno.');
} }
} }
@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SmsLog;
use App\Models\SmsProfile;
use App\Models\SmsTemplate;
use Illuminate\Http\Request;
use Inertia\Inertia;
class SmsLogController extends Controller
{
public function index(Request $request)
{
$query = SmsLog::query()->with(['profile:id,name', 'template:id,name,slug']);
// Filters
$status = $request->string('status')->toString();
$profileId = $request->integer('profile_id');
$templateId = $request->integer('template_id');
$search = trim((string) $request->input('search', ''));
$from = $request->date('from');
$to = $request->date('to');
if ($status !== '') {
$query->where('status', $status);
}
if ($profileId) {
$query->where('profile_id', $profileId);
}
if ($templateId) {
$query->where('template_id', $templateId);
}
if ($search !== '') {
$query->where(function ($q) use ($search): void {
$q->where('to_number', 'ILIKE', "%$search%")
->orWhere('sender', 'ILIKE', "%$search%")
->orWhere('provider_message_id', 'ILIKE', "%$search%")
->orWhere('message', 'ILIKE', "%$search%");
});
}
if ($from) {
$query->whereDate('created_at', '>=', $from);
}
if ($to) {
$query->whereDate('created_at', '<=', $to);
}
$logs = $query->orderByDesc('id')->paginate(20)->withQueryString();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json($logs);
}
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
$templates = SmsTemplate::query()->orderBy('name')->get(['id', 'name', 'slug']);
return Inertia::render('Admin/SmsLogs/Index', [
'logs' => $logs,
'profiles' => $profiles,
'templates' => $templates,
'filters' => [
'status' => $status ?: null,
'profile_id' => $profileId ?: null,
'template_id' => $templateId ?: null,
'search' => $search ?: null,
'from' => $from ? $from->format('Y-m-d') : null,
'to' => $to ? $to->format('Y-m-d') : null,
],
]);
}
public function show(SmsLog $smsLog)
{
$smsLog->load(['profile:id,name', 'template:id,name,slug']);
return Inertia::render('Admin/SmsLogs/Show', [
'log' => $smsLog,
]);
}
}
@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSmsProfileRequest;
use App\Http\Requests\TestSendSmsRequest;
use App\Jobs\SendSmsJob;
use App\Models\SmsProfile;
use App\Models\SmsSender;
use App\Services\Sms\SmsService;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
class SmsProfileController extends Controller
{
public function index(Request $request)
{
$profiles = SmsProfile::query()->with(['senders:id,profile_id,sname,active'])->orderBy('name')->get([
'id', 'uuid', 'name', 'active', 'api_username', 'default_sender_id', 'settings', 'created_at', 'updated_at',
]);
// Inertia requests must receive an Inertia response
if ($request->headers->has('X-Inertia')) {
return Inertia::render('Admin/SmsProfiles/Index', [
'initialProfiles' => $profiles,
]);
}
// JSON/AJAX API
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['profiles' => $profiles]);
}
// Default to Inertia page for normal browser navigation
return Inertia::render('Admin/SmsProfiles/Index', [
'initialProfiles' => $profiles,
]);
}
public function store(StoreSmsProfileRequest $request)
{
$data = $request->validated();
$profile = new SmsProfile;
$profile->uuid = (string) Str::uuid();
$profile->name = $data['name'];
$profile->active = (bool) ($data['active'] ?? true);
$profile->api_username = $data['api_username'];
// write-only attribute setter will encrypt and store to encrypted_api_password
$profile->api_password = $data['api_password'];
$profile->save();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['profile' => $profile], 201);
}
return back()->with('success', 'SMS profil je ustvarjen.');
}
public function testSend(SmsProfile $profile, TestSendSmsRequest $request, SmsService $sms)
{
$data = $request->validated();
$sender = null;
if (! empty($data['sender_id'])) {
$sender = SmsSender::query()->where('id', $data['sender_id'])->where('profile_id', $profile->id)->firstOrFail();
}
// Queue the SMS send (admin test send - no activity created)
SendSmsJob::dispatch(
profileId: $profile->id,
to: (string) $data['to'],
content: (string) $data['message'],
senderId: $sender?->id,
countryCode: $data['country_code'] ?? null,
deliveryReport: (bool) ($data['delivery_report'] ?? false),
clientReference: null,
);
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['queued' => true]);
}
return back()->with('success', 'Testni SMS je bil dodan v čakalno vrsto.');
}
public function balance(SmsProfile $smsProfile, SmsService $sms)
{
try {
$balance = (string) $sms->getCreditBalance($smsProfile);
return response()->json(['balance' => $balance]);
} catch (\Throwable $e) {
// Return a graceful payload so UI doesn't break; also include message for optional UI/tooling
return response()->json([
'balance' => '—',
'error' => 'Unable to fetch balance: '.$e->getMessage(),
]);
}
}
public function price(SmsProfile $smsProfile, SmsService $sms)
{
$quotes = $sms->getPriceQuotes($smsProfile);
return response()->json(['quotes' => $quotes]);
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSmsSenderRequest;
use App\Http\Requests\UpdateSmsSenderRequest;
use App\Models\SmsProfile;
use App\Models\SmsSender;
use Illuminate\Http\Request;
use Inertia\Inertia;
class SmsSenderController extends Controller
{
public function index(Request $request)
{
$senders = SmsSender::query()
->with(['profile:id,name'])
->orderBy('id', 'desc')
->get(['id', 'profile_id', 'sname', 'phone_number', 'description', 'active', 'created_at']);
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
return Inertia::render('Admin/SmsSenders/Index', [
'initialSenders' => $senders,
'profiles' => $profiles,
]);
}
public function store(StoreSmsSenderRequest $request)
{
$data = $request->validated();
$sender = SmsSender::create([
'profile_id' => $data['profile_id'],
'sname' => $data['sname'],
'phone_number' => $data['phone_number'] ?? null,
'description' => $data['description'] ?? null,
'active' => (bool) ($data['active'] ?? true),
]);
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['sender' => $sender], 201);
}
return back()->with('success', 'Pošiljatelj je ustvarjen.');
}
public function update(UpdateSmsSenderRequest $request, SmsSender $smsSender)
{
$data = $request->validated();
$smsSender->forceFill([
'profile_id' => $data['profile_id'],
'sname' => $data['sname'],
'phone_number' => $data['phone_number'] ?? null,
'description' => $data['description'] ?? null,
'active' => (bool) ($data['active'] ?? $smsSender->active),
])->save();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['sender' => $smsSender]);
}
return back()->with('success', 'Pošiljatelj je posodobljen.');
}
public function toggle(Request $request, SmsSender $smsSender)
{
$smsSender->active = ! $smsSender->active;
$smsSender->save();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['sender' => $smsSender]);
}
return back()->with('success', 'Stanje pošiljatelja je posodobljeno.');
}
public function destroy(Request $request, SmsSender $smsSender)
{
$smsSender->delete();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['deleted' => true]);
}
return back()->with('success', 'Pošiljatelj je izbrisan.');
}
}
@@ -0,0 +1,199 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSmsTemplateRequest;
use App\Http\Requests\TestSendSmsTemplateRequest;
use App\Http\Requests\UpdateSmsTemplateRequest;
use App\Models\SmsProfile;
use App\Models\SmsSender;
use App\Models\SmsTemplate;
use App\Services\Sms\SmsService;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
class SmsTemplateController extends Controller
{
public function index(Request $request)
{
$templates = SmsTemplate::query()
->with(['defaultProfile:id,name', 'defaultSender:id,sname'])
->orderBy('name')
->get(['id', 'uuid', 'name', 'slug', 'content', 'variables_json', 'is_active', 'default_profile_id', 'default_sender_id', 'created_at']);
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
$senders = SmsSender::query()->orderBy('sname')->get(['id', 'profile_id', 'sname', 'active']);
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json([
'templates' => $templates,
'profiles' => $profiles,
'senders' => $senders,
]);
}
return Inertia::render('Admin/SmsTemplates/Index', [
'initialTemplates' => $templates,
'profiles' => $profiles,
'senders' => $senders,
]);
}
public function create()
{
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
$senders = SmsSender::query()->orderBy('sname')->get(['id', 'profile_id', 'sname', 'active']);
$actions = \App\Models\Action::query()
->with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/SmsTemplates/Edit', [
'template' => null,
'profiles' => $profiles,
'senders' => $senders,
'actions' => $actions,
]);
}
public function edit(SmsTemplate $smsTemplate)
{
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
$senders = SmsSender::query()->orderBy('sname')->get(['id', 'profile_id', 'sname', 'active']);
$actions = \App\Models\Action::query()
->with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/SmsTemplates/Edit', [
'template' => $smsTemplate->only(['id', 'uuid', 'name', 'slug', 'content', 'variables_json', 'is_active', 'default_profile_id', 'default_sender_id', 'allow_custom_body', 'action_id', 'decision_id']),
'profiles' => $profiles,
'senders' => $senders,
'actions' => $actions,
]);
}
public function store(StoreSmsTemplateRequest $request)
{
$data = $request->validated();
$tpl = new SmsTemplate;
$tpl->uuid = (string) Str::uuid();
$tpl->name = $data['name'];
$tpl->slug = $data['slug'];
$tpl->content = $data['content'] ?? '';
$tpl->variables_json = $data['variables_json'] ?? null;
$tpl->is_active = (bool) ($data['is_active'] ?? true);
$tpl->default_profile_id = $data['default_profile_id'] ?? null;
$tpl->default_sender_id = $data['default_sender_id'] ?? null;
$tpl->allow_custom_body = (bool) ($data['allow_custom_body'] ?? false);
$tpl->action_id = $data['action_id'] ?? null;
$tpl->decision_id = $data['decision_id'] ?? null;
$tpl->save();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['template' => $tpl], 201);
}
return redirect()->route('admin.sms-templates.edit', $tpl);
}
public function update(UpdateSmsTemplateRequest $request, SmsTemplate $smsTemplate)
{
$data = $request->validated();
$smsTemplate->forceFill([
'name' => $data['name'],
'slug' => $data['slug'],
'content' => $data['content'] ?? '',
'variables_json' => $data['variables_json'] ?? null,
'is_active' => (bool) ($data['is_active'] ?? $smsTemplate->is_active),
'default_profile_id' => $data['default_profile_id'] ?? null,
'default_sender_id' => $data['default_sender_id'] ?? null,
'allow_custom_body' => (bool) ($data['allow_custom_body'] ?? $smsTemplate->allow_custom_body),
'action_id' => $data['action_id'] ?? null,
'decision_id' => $data['decision_id'] ?? null,
])->save();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['template' => $smsTemplate]);
}
return back()->with('success', 'SMS predloga je posodobljena.');
}
public function toggle(Request $request, SmsTemplate $smsTemplate)
{
$smsTemplate->is_active = ! $smsTemplate->is_active;
$smsTemplate->save();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['template' => $smsTemplate]);
}
return back()->with('success', 'Stanje predloge je posodobljeno.');
}
public function destroy(Request $request, SmsTemplate $smsTemplate)
{
$smsTemplate->delete();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['deleted' => true]);
}
return back()->with('success', 'Predloga je izbrisana.');
}
public function sendTest(TestSendSmsTemplateRequest $request, SmsTemplate $smsTemplate, SmsService $sms)
{
$data = $request->validated();
$profile = null;
if (! empty($data['profile_id'])) {
$profile = SmsProfile::query()->findOrFail($data['profile_id']);
}
$sender = null;
if (! empty($data['sender_id'])) {
$sender = SmsSender::query()->findOrFail($data['sender_id']);
}
$variables = (array) ($data['variables'] ?? []);
if (! empty($data['custom_content']) && $smsTemplate->allow_custom_body) {
// Use custom content when allowed
if (! $profile) {
$profile = $smsTemplate->defaultProfile;
}
if (! $profile) {
throw new \InvalidArgumentException('SMS profile is required to send a message.');
}
$log = $sms->sendRaw(
profile: $profile,
to: $data['to'],
content: (string) $data['custom_content'],
sender: $sender,
countryCode: $data['country_code'] ?? null,
deliveryReport: (bool) ($data['delivery_report'] ?? false),
);
$log->template_id = $smsTemplate->id;
$log->save();
} else {
$log = $sms->sendFromTemplate(
template: $smsTemplate,
to: $data['to'],
variables: $variables,
profile: $profile,
sender: $sender,
countryCode: $data['country_code'] ?? null,
deliveryReport: (bool) ($data['delivery_report'] ?? false),
);
}
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['log' => $log]);
}
return back()->with('success', 'Testni SMS je bil poslan.');
}
}
@@ -3,12 +3,14 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreUserRequest;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -18,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']); $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']);
@@ -29,6 +31,23 @@ public function index(Request $request): Response
]); ]);
} }
public function store(StoreUserRequest $request): RedirectResponse
{
$validated = $request->validated();
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
if (! empty($validated['roles'])) {
$user->roles()->sync($validated['roles']);
}
return back()->with('success', 'Uporabnik uspešno ustvarjen');
}
public function update(Request $request, User $user): RedirectResponse public function update(Request $request, User $user): RedirectResponse
{ {
Gate::authorize('manage-settings'); Gate::authorize('manage-settings');
@@ -42,4 +61,29 @@ public function update(Request $request, User $user): RedirectResponse
return back()->with('success', 'Roles updated'); return back()->with('success', 'Roles updated');
} }
public function toggleActive(User $user): RedirectResponse
{
Gate::authorize('manage-settings');
$user->active = ! $user->active;
$user->save();
$status = $user->active ? 'aktiviran' : 'deaktiviran';
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.');
}
}
@@ -5,7 +5,6 @@
use App\Models\CaseObject; use App\Models\CaseObject;
use App\Models\ClientCase; use App\Models\ClientCase;
use App\Models\Contract; use App\Models\Contract;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class CaseObjectController extends Controller class CaseObjectController extends Controller
@@ -27,8 +26,8 @@ public function store(ClientCase $clientCase, string $uuid, Request $request)
public function update(ClientCase $clientCase, int $id, Request $request) public function update(ClientCase $clientCase, int $id, Request $request)
{ {
$object = CaseObject::where('id', $id) $object = CaseObject::where('id', $id)
->whereHas('contract', fn($q) => $q->where('client_case_id', $clientCase->id)) ->whereHas('contract', fn ($q) => $q->where('client_case_id', $clientCase->id))
->firstOrFail(); ->firstOrFail();
$validated = $request->validate([ $validated = $request->validate([
@@ -45,8 +44,8 @@ public function update(ClientCase $clientCase, int $id, Request $request)
public function destroy(ClientCase $clientCase, int $id) public function destroy(ClientCase $clientCase, int $id)
{ {
$object = CaseObject::where('id', $id) $object = CaseObject::where('id', $id)
->whereHas('contract', fn($q) => $q->where('client_case_id', $clientCase->id)) ->whereHas('contract', fn ($q) => $q->where('client_case_id', $clientCase->id))
->firstOrFail(); ->firstOrFail();
$object->delete(); $object->delete();
File diff suppressed because it is too large Load Diff
+278 -74
View File
@@ -2,58 +2,52 @@
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 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
{ {
public function __construct(protected ReferenceDataCache $referenceCache) {}
public function index(Client $client, Request $request) public function index(Client $client, Request $request)
{ {
$search = $request->input('search');
$query = $client::query() $query = $client::query()
->with('person') ->select('clients.*')
->when($request->input('search'), function ($que, $search) { ->when($search, function ($que) use ($search) {
$que->whereHas('person', function ($q) use ($search) { $que->join('person', 'person.id', '=', 'clients.person_id')
$q->where('full_name', 'ilike', '%'.$search.'%'); ->where('person.full_name', 'ilike', '%'.$search.'%')
}); ->groupBy('clients.id');
}) })
->where('active', 1) // ->where('clients.active', 1)
->addSelect([ // Use LEFT JOINs for aggregated data to avoid subqueries
// Number of client cases for this client that have at least one active contract ->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
'cases_with_active_contracts_count' => DB::query() ->leftJoin('contracts', function ($join) {
->from('client_cases') $join->on('contracts.client_case_id', '=', 'client_cases.id')
->join('contracts', 'contracts.client_case_id', '=', 'client_cases.id') ->whereNull('contracts.deleted_at');
->selectRaw('COUNT(DISTINCT client_cases.id)') })
->whereColumn('client_cases.client_id', 'clients.id') ->leftJoin('contract_segment', function ($join) {
->whereNull('contracts.deleted_at') $join->on('contract_segment.contract_id', '=', 'contracts.id')
->whereExists(function ($q) { ->where('contract_segment.active', true);
$q->from('contract_segment') })
->whereColumn('contract_segment.contract_id', 'contracts.id') ->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true); ->groupBy('clients.id')
}), ->selectRaw('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 that belong to this client's cases ->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
'active_contracts_balance_sum' => DB::query() ->with('person')
->from('contracts') ->orderByDesc('clients.created_at');
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
->whereExists(function ($q) {
$q->from('client_cases')
->whereColumn('client_cases.id', 'contracts.client_case_id')
->whereColumn('client_cases.client_id', 'clients.id');
})
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
])
->orderByDesc('created_at');
return Inertia::render('Client/Index', [ return Inertia::render('Client/Index', [
'clients' => $query 'clients' => $query
->paginate(15) ->paginate($request->integer('per_page', default: 100))
->withQueryString(), ->withQueryString(),
'filters' => $request->only(['search']), 'filters' => $request->only(['search']),
]); ]);
@@ -63,55 +57,194 @@ public function show(Client $client, Request $request)
{ {
$data = $client::query() $data = $client::query()
->with(['person' => fn ($que) => $que->with(['addresses', 'phones', 'bankAccounts'])]) ->with(['person' => fn ($que) => $que->with(['addresses', 'phones', 'bankAccounts', 'emails', 'client'])])
->findOrFail($client->id); ->findOrFail($client->id);
$types = [ $types = [
'address_types' => \App\Models\Person\AddressType::all(), 'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => \App\Models\Person\PhoneType::all(), 'phone_types' => $this->referenceCache->getPhoneTypes(),
]; ];
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.*')
->when($request->input('search'), function ($que, $search) {
$que->join('person', 'person.id', '=', 'client_cases.person_id')
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('client_cases.id');
})
->leftJoin('contracts', function ($join) {
$join->on('contracts.client_case_id', '=', 'client_cases.id')
->whereNull('contracts.deleted_at');
})
->leftJoin('contract_segment', function ($join) {
$join->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true);
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('client_cases.id')
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
->with(['person', 'client.person']) ->with(['person', 'client.person'])
->when($request->input('search'), fn ($que, $search) => $que->whereHas( ->where('client_cases.active', 1)
'person', ->orderByDesc('client_cases.created_at')
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%') ->paginate($request->integer('per_page', 15))
)
)
->addSelect([
'active_contracts_count' => \DB::query()
->from('contracts')
->selectRaw('COUNT(*)')
->whereColumn('contracts.client_case_id', 'client_cases.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
'active_contracts_balance_sum' => \DB::query()
->from('contracts')
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
->whereColumn('contracts.client_case_id', 'client_cases.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
])
->where('active', 1)
->orderByDesc('created_at')
->paginate(15)
->withQueryString(), ->withQueryString(),
'types' => $types, 'types' => $types,
'filters' => $request->only(['search']), 'filters' => $request->only(['search']),
]); ]);
} }
public function contracts(Client $client, Request $request)
{
$data = $client->load(['person' => fn ($q) => $q->with(['addresses', 'phones', 'bankAccounts', 'emails'])]);
$from = $request->input('from');
$to = $request->input('to');
$search = $request->input('search');
$segmentsParam = $request->input('segments');
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
$contractsQuery = \App\Models\Contract::query()
->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.start_date', 'contracts.client_case_id'])
->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
->where('client_cases.client_id', $client->id)
->whereNull('contracts.deleted_at')
->when($from || $to, function ($q) use ($from, $to) {
if (! empty($from)) {
$q->whereDate('contracts.start_date', '>=', $from);
}
if (! empty($to)) {
$q->whereDate('contracts.start_date', '<=', $to);
}
})
->when($search, function ($q) use ($search) {
$q->leftJoin('person', 'person.id', '=', 'client_cases.person_id')
->where(function ($inner) use ($search) {
$inner->where('contracts.reference', 'ilike', '%'.$search.'%')
->orWhere('person.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);
});
})
->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',
])
->orderByDesc('contracts.start_date');
$segments = \App\Models\Segment::orderBy('name')->get(['id', 'name']);
$types = [
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
];
// Support custom pagination parameter names used by DataTableNew2
$perPage = $request->integer('contracts_per_page', $request->integer('per_page', 15));
$pageNumber = $request->integer('contracts_page', $request->integer('page', 1));
return Inertia::render('Client/Contracts', [
'client' => $data,
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
'contracts' => $contractsQuery
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
->withQueryString(),
'filters' => $request->only(['from', 'to', 'search', 'segment']),
'segments' => $segments,
'types' => $types,
]);
}
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)
{ {
@@ -149,13 +282,84 @@ public function store(Request $request)
// \App\Models\Person\PersonAddress::create($address); // \App\Models\Person\PersonAddress::create($address);
return to_route('client'); return back()->with('success', 'Client created')->with('flash_method', 'POST');
} }
public function update(Client $client, Request $request) public function update(Client $client, Request $request)
{ {
return to_route('client.show', $client); return back()->with('success', 'Client updated')->with('flash_method', 'PUT');
}
/**
* Emergency endpoint: if the linked person record is missing (hard deleted) or soft deleted,
* create a new minimal Person and re-point all related child records (emails, phones, addresses, bank accounts,
* client cases) from the old person_id to the new one, then update the client itself.
*/
public function emergencyCreatePerson(Client $client, Request $request)
{
$oldPersonId = $client->person_id;
// If person exists and is not trashed, abort nothing to do
/** @var \App\Models\Person\Person|null $existing */
$existing = \App\Models\Person\Person::withTrashed()->find($oldPersonId);
if ($existing && ! $existing->trashed()) {
return redirect()->back()->with('flash', [
'type' => 'info',
'message' => 'Person already exists emergency creation not needed.',
]);
}
$data = $request->validate([
'full_name' => ['nullable', 'string', 'max:255'],
'first_name' => ['nullable', 'string', 'max:255'],
'last_name' => ['nullable', 'string', 'max:255'],
'tax_number' => ['nullable', 'string', 'max:99'],
'social_security_number' => ['nullable', 'string', 'max:99'],
'description' => ['nullable', 'string', 'max:500'],
]);
// Provide sensible fallbacks.
$fullName = $data['full_name'] ?? trim(($data['first_name'] ?? '').' '.($data['last_name'] ?? ''));
if ($fullName === '') {
$fullName = 'Unknown Person';
}
$newPerson = null;
\DB::transaction(function () use ($oldPersonId, $client, $fullName, $data, &$newPerson) {
$newPerson = \App\Models\Person\Person::create([
'nu' => null, // boot event will generate
'first_name' => $data['first_name'] ?? null,
'last_name' => $data['last_name'] ?? null,
'full_name' => $fullName,
'gender' => null,
'birthday' => null,
'tax_number' => $data['tax_number'] ?? null,
'social_security_number' => $data['social_security_number'] ?? null,
'description' => $data['description'] ?? 'Emergency recreated person',
'group_id' => 1,
'type_id' => 2,
]);
// Re-point related records referencing the old (missing) person id
$tables = [
'emails', 'person_phones', 'person_addresses', 'bank_accounts', 'client_cases',
];
foreach ($tables as $table) {
\DB::table($table)->where('person_id', $oldPersonId)->update(['person_id' => $newPerson->id]);
}
// Finally update the client itself (only this one; avoid touching other potential clients)
$client->person_id = $newPerson->id;
$client->save();
});
return redirect()->back()->with('flash', [
'type' => 'success',
'message' => 'New person created and related records re-linked.',
'person_uuid' => $newPerson?->uuid,
]);
} }
} }
@@ -14,8 +14,8 @@ public function index()
{ {
return Inertia::render('Settings/ContractConfigs/Index', [ return Inertia::render('Settings/ContractConfigs/Index', [
'configs' => ContractConfig::with(['type:id,name', 'segment:id,name'])->get(), 'configs' => ContractConfig::with(['type:id,name', 'segment:id,name'])->get(),
'types' => ContractType::query()->get(['id','name']), 'types' => ContractType::query()->get(['id', 'name']),
'segments' => Segment::query()->where('active', true)->get(['id','name']), 'segments' => Segment::query()->where('active', true)->get(['id', 'name']),
]); ]);
} }
@@ -40,8 +40,8 @@ public function store(Request $request)
ContractConfig::create([ ContractConfig::create([
'contract_type_id' => $data['contract_type_id'], 'contract_type_id' => $data['contract_type_id'],
'segment_id' => $data['segment_id'], 'segment_id' => $data['segment_id'],
'is_initial' => (bool)($data['is_initial'] ?? false), 'is_initial' => (bool) ($data['is_initial'] ?? false),
'active' => (bool)($data['active'] ?? true), 'active' => (bool) ($data['active'] ?? true),
]); ]);
return back()->with('success', 'Configuration created'); return back()->with('success', 'Configuration created');
@@ -57,8 +57,8 @@ public function update(ContractConfig $config, Request $request)
$config->update([ $config->update([
'segment_id' => $data['segment_id'], 'segment_id' => $data['segment_id'],
'is_initial' => (bool)($data['is_initial'] ?? $config->is_initial), 'is_initial' => (bool) ($data['is_initial'] ?? $config->is_initial),
'active' => (bool)($data['active'] ?? $config->active), 'active' => (bool) ($data['active'] ?? $config->active),
]); ]);
return back()->with('success', 'Configuration updated'); return back()->with('success', 'Configuration updated');
@@ -67,6 +67,7 @@ public function update(ContractConfig $config, Request $request)
public function destroy(ContractConfig $config) public function destroy(ContractConfig $config)
{ {
$config->delete(); $config->delete();
return back()->with('success', 'Configuration deleted'); return back()->with('success', 'Configuration deleted');
} }
} }
+82 -13
View File
@@ -4,26 +4,28 @@
use App\Models\Contract; use App\Models\Contract;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia; use Inertia\Inertia;
class ContractController extends Controller class ContractController extends Controller
{ {
public function index(Contract $contract)
public function index(Contract $contract) { {
return Inertia::render('Contract/Index', [ return Inertia::render('Contract/Index', [
'contracts' => $contract::with(['type', 'debtor']) 'contracts' => $contract::with(['type', 'debtor'])
->where('active', 1) ->where('active', 1)
->orderByDesc('created_at') ->orderByDesc('created_at')
->paginate(10), ->paginate(10),
'person_types' => \App\Models\Person\PersonType::all(['id', 'name', 'description']) 'person_types' => \App\Models\Person\PersonType::all(['id', 'name', 'description'])
->where('deleted', 0) ->where('deleted', 0),
]); ]);
} }
public function show(Contract $contract){ public function show(Contract $contract)
{
return inertia('Contract/Show', [ return inertia('Contract/Show', [
'contract' => $contract::with(['type', 'client', 'debtor'])->findOrFail($contract->id) 'contract' => $contract::with(['type', 'client', 'debtor'])->findOrFail($contract->id),
]); ]);
} }
@@ -33,29 +35,96 @@ public function store(Request $request)
$clientCase = \App\Models\ClientCase::where('uuid', $uuid)->firstOrFail(); $clientCase = \App\Models\ClientCase::where('uuid', $uuid)->firstOrFail();
if( isset($clientCase->id) ){ if (isset($clientCase->id)) {
\DB::transaction(function() use ($request, $clientCase){ \DB::transaction(function () use ($request, $clientCase) {
//Create contract // Create contract
$clientCase->contracts()->create([ $clientCase->contracts()->create([
'reference' => $request->input('reference'), 'reference' => $request->input('reference'),
'start_date' => date('Y-m-d', strtotime($request->input('start_date'))), 'start_date' => date('Y-m-d', strtotime($request->input('start_date'))),
'type_id' => $request->input('type_id') 'type_id' => $request->input('type_id'),
]); ]);
}); });
} }
return to_route('clientCase.show', $clientCase); return back()->with('success', 'Contract created')->with('flash_method', 'POST');
} }
public function update(Contract $contract, Request $request){ public function update(Contract $contract, Request $request)
{
$contract->update([ $contract->update([
'referenca' => $request->input('referenca'), 'referenca' => $request->input('referenca'),
'type_id' => $request->input('type_id') 'type_id' => $request->input('type_id'),
]); ]);
}
public function segment(Request $request)
{
$data = $request->validate([
'segment_id' => ['required', 'integer', Rule::exists('segments', 'id')->where('active', true)],
'contracts' => ['required', 'array', 'min:1'],
'contracts.*' => ['string', Rule::exists('contracts', 'uuid')],
]);
$segmentId = (int) $data['segment_id'];
$uuids = array_values($data['contracts']);
$contracts = Contract::query()
->whereIn('uuid', $uuids)
->get(['id', 'client_case_id']);
DB::transaction(function () use ($contracts, $segmentId) {
foreach ($contracts as $contract) {
// Ensure the segment is attached to the client case and active
$attached = DB::table('client_case_segment')
->where('client_case_id', $contract->client_case_id)
->where('segment_id', $segmentId)
->first();
if (! $attached) {
DB::table('client_case_segment')->insert([
'client_case_id' => $contract->client_case_id,
'segment_id' => $segmentId,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
} elseif (! $attached->active) {
DB::table('client_case_segment')
->where('id', $attached->id)
->update(['active' => true, 'updated_at' => now()]);
}
// Deactivate all current contract segments
DB::table('contract_segment')
->where('contract_id', $contract->id)
->update(['active' => false, 'updated_at' => now()]);
// Activate or attach the target segment
$pivot = DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('segment_id', $segmentId)
->first();
if ($pivot) {
DB::table('contract_segment')
->where('id', $pivot->id)
->update(['active' => true, 'updated_at' => now()]);
} else {
DB::table('contract_segment')->insert([
'contract_id' => $contract->id,
'segment_id' => $segmentId,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
});
return back()->with('success', __('Pogodbe so bile preusmerjene v izbrani segment.'));
} }
} }
@@ -8,6 +8,7 @@
use App\Models\Document; use App\Models\Document;
use App\Models\DocumentTemplate; use App\Models\DocumentTemplate;
use App\Services\Documents\TokenValueResolver; use App\Services\Documents\TokenValueResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
@@ -16,38 +17,77 @@
class ContractDocumentGenerationController extends Controller class ContractDocumentGenerationController extends Controller
{ {
public function __invoke(Request $request, Contract $contract): Response public function __invoke(Request $request, Contract $contract): Response|RedirectResponse
{ {
// Inertia requests include the X-Inertia header and should receive redirects or Inertia responses, not JSON
$isInertia = (bool) $request->header('X-Inertia');
// For non-Inertia POSTs, prefer JSON responses by default (including tests)
$wantsJson = ! $isInertia;
if (Gate::denies('read')) { // baseline read permission required to generate if (Gate::denies('read')) { // baseline read permission required to generate
abort(403); abort(403);
} }
$request->validate([ $request->validate([
'template_slug' => ['required', 'string', 'exists:document_templates,slug'], 'template_slug' => ['required', 'string', 'exists:document_templates,slug'],
'template_version' => ['nullable', 'integer'],
'custom' => ['nullable', 'array'],
'custom.*' => ['nullable'],
]); ]);
$template = DocumentTemplate::where('slug', $request->template_slug) // Prefer explicitly requested version if provided and active; otherwise use latest active
$baseQuery = DocumentTemplate::query()
->where('slug', $request->template_slug)
->where('core_entity', 'contract') ->where('core_entity', 'contract')
->where('active', true) ->where('active', true);
->orderByDesc('version') if ($request->filled('template_version')) {
->firstOrFail(); $template = (clone $baseQuery)->where('version', (int) $request->integer('template_version'))->first();
if (! $template) {
$template = (clone $baseQuery)->orderByDesc('version')->firstOrFail();
}
} else {
$template = $baseQuery->orderByDesc('version')->firstOrFail();
}
// Load related data minimally // Load related data minimally
$contract->load(['clientCase.client.person', 'clientCase.person', 'clientCase.client']); $contract->load(['clientCase.client.person', 'clientCase.person', 'clientCase.client']);
$renderer = app(\App\Services\Documents\DocxTemplateRenderer::class); $renderer = app(\App\Services\Documents\DocxTemplateRenderer::class);
try { try {
// For custom tokens: pass overrides via request bag; service already reads request()->input('custom') if present.
$result = $renderer->render($template, $contract, Auth::user()); $result = $renderer->render($template, $contract, Auth::user());
} catch (\App\Services\Documents\Exceptions\UnresolvedTokensException $e) { } catch (\App\Services\Documents\Exceptions\UnresolvedTokensException $e) {
return response()->json([ if ($wantsJson) {
'status' => 'error', return response()->json([
'message' => 'Unresolved tokens detected.', 'status' => 'error',
'tokens' => $e->unresolved ?? [], 'message' => 'Unresolved tokens detected.',
], 422); 'tokens' => $e->unresolved ?? [],
], 500);
}
// Return back with validation-like errors so Inertia can surface them via onError
return back()->withErrors([
'document' => 'Unresolved tokens detected.',
])->with('unresolved_tokens', $e->unresolved ?? []);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return response()->json([ try {
'status' => 'error', logger()->error('ContractDocumentGenerationController generation failed', [
'message' => 'Generation failed.', 'template_id' => $template->id ?? null,
], 500); 'template_slug' => $template->slug ?? null,
'template_version' => $template->version ?? null,
'error' => $e->getMessage(),
]);
} catch (\Throwable $logEx) {
}
if ($wantsJson) {
return response()->json([
'status' => 'error',
'message' => 'Generation failed.',
], 500);
}
return back()->withErrors([
'document' => 'Generation failed.',
]);
} }
$doc = new Document; $doc = new Document;
@@ -108,10 +148,32 @@ public function __invoke(Request $request, Contract $contract): Response
} }
} }
return response()->json([ if ($wantsJson) {
'status' => 'ok', return response()->json([
'document_uuid' => $doc->uuid, 'status' => 'ok',
'path' => $doc->path, 'document_uuid' => $doc->uuid,
'path' => $doc->path,
'stats' => $result['stats'] ?? null,
'template' => [
'id' => $template->id,
'slug' => $template->slug,
'version' => $template->version,
'file_path' => $template->file_path,
],
]);
}
// Flash some lightweight info if needed by the UI; Inertia will GET the page after redirect
return back()->with([
'doc_generated' => [
'uuid' => $doc->uuid,
'path' => $doc->path,
'template' => [
'slug' => $template->slug,
'version' => $template->version,
],
'stats' => $result['stats'] ?? null,
],
]); ]);
} }
} }
@@ -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.');
}
}
+173 -146
View File
@@ -2,188 +2,215 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\Activity;
use App\Models\Client; use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Document;
use App\Models\FieldJob;
use App\Models\Import; // assuming model name Import
use App\Models\Activity; // if this model exists
use App\Models\Contract; use App\Models\Contract;
use Illuminate\Support\Carbon; // assuming model name Import
use Illuminate\Support\Facades\Schema; use App\Models\FieldJob; // if this model exists
use App\Models\Import;
use App\Models\SmsLog;
use App\Models\SmsProfile;
use App\Services\Sms\SmsService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\CarbonPeriod;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
class DashboardController extends Controller class DashboardController extends Controller
{ {
public function __invoke(): Response public function __invoke(SmsService $sms): Response
{ {
$today = now()->startOfDay(); $today = now()->startOfDay();
$yesterday = now()->subDay()->startOfDay(); $cacheMinutes = 5;
$staleThreshold = now()->subDays(7); // assumption: stale if no activity in last 7 days
$clientsTotal = Client::count(); // Active clients count - cached
$clientsNew7d = Client::where('created_at', '>=', now()->subDays(7))->count(); $activeClientsCount = Cache::remember('dashboard:active_clients:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
// FieldJob table does not have a scheduled_at column (schema shows: assigned_at, completed_at, cancelled_at) return Client::where('active', true)->count();
// Temporary logic: if scheduled_at ever added we'll use it; otherwise fall back to assigned_at then created_at. });
if (Schema::hasColumn('field_jobs', 'scheduled_at')) {
$fieldJobsToday = FieldJob::whereDate('scheduled_at', $today)->count();
} else {
// Prefer assigned_at when present, otherwise created_at
$fieldJobsToday = FieldJob::whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)->count();
}
$documentsToday = Document::whereDate('created_at', $today)->count();
$activeImports = Import::whereIn('status', ['queued', 'processing'])->count();
$activeContracts = Contract::where('active', 1)->count();
// Basic activities deferred list (limit 10) // Active contracts count - cached
$activities = Activity::query() $activeContractsCount = Cache::remember('dashboard:active_contracts:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
->with(['clientCase:id,uuid']) return Contract::whereNull('deleted_at')->count();
->latest() });
->limit(10)
->get(['id','note','created_at','client_case_id','contract_id','action_id','decision_id'])
->map(fn($a) => [
'id' => $a->id,
'note' => $a->note,
'created_at' => $a->created_at,
'client_case_id' => $a->client_case_id,
'client_case_uuid' => $a->clientCase?->uuid,
'contract_id' => $a->contract_id,
'action_id' => $a->action_id,
'decision_id' => $a->decision_id,
]);
// 7-day trends (including today) // Sum of active contracts' account balance - cached
$start = now()->subDays(6)->startOfDay(); $totalBalance = Cache::remember('dashboard:total_balance:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
$end = now()->endOfDay(); return Account::whereHas('contract', function ($q) {
$q->whereNull('deleted_at');
})->sum('balance_amount') ?? 0;
});
$dateKeys = collect(range(0,6)) // Active promises count (not expired or expires today) - cached
->map(fn($i) => now()->subDays(6 - $i)->format('Y-m-d')); $activePromisesCount = Cache::remember('dashboard:active_promises:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
return Account::whereHas('contract', function ($q) {
$clientTrendRaw = Client::whereBetween('created_at', [$start,$end]) $q->whereNull('deleted_at');
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c','d');
$documentTrendRaw = Document::whereBetween('created_at', [$start,$end])
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c','d');
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start,$end])
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c','d');
$importTrendRaw = Import::whereBetween('created_at', [$start,$end])
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c','d');
// Completed field jobs last 7 days
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
->whereBetween('completed_at', [$start, $end])
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c','d');
$trends = [
'clients_new' => $dateKeys->map(fn($d) => (int) ($clientTrendRaw[$d] ?? 0))->values(),
'documents_new' => $dateKeys->map(fn($d) => (int) ($documentTrendRaw[$d] ?? 0))->values(),
'field_jobs' => $dateKeys->map(fn($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(),
'imports_new' => $dateKeys->map(fn($d) => (int) ($importTrendRaw[$d] ?? 0))->values(),
'field_jobs_completed' => $dateKeys->map(fn($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
'labels' => $dateKeys,
];
// Stale client cases (no activity in last 7 days)
$staleCases = \App\Models\ClientCase::query()
->leftJoin('activities', function($join) {
$join->on('activities.client_case_id', '=', 'client_cases.id')
->whereNull('activities.deleted_at');
}) })
->selectRaw('client_cases.id, client_cases.uuid, client_cases.client_ref, MAX(activities.created_at) as last_activity_at, client_cases.created_at') ->whereNotNull('promise_date')
->groupBy('client_cases.id','client_cases.uuid','client_cases.client_ref','client_cases.created_at') ->whereDate('promise_date', '>=', $today)
->havingRaw('(MAX(activities.created_at) IS NULL OR MAX(activities.created_at) < ?) AND client_cases.created_at < ?', [$staleThreshold, $staleThreshold]) ->count();
->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC') });
->limit(10)
->get()
->map(fn($c) => [
'id' => $c->id,
'uuid' => $c->uuid,
'client_ref' => $c->client_ref,
'last_activity_at' => $c->last_activity_at,
'created_at' => $c->created_at,
'days_stale' => $c->last_activity_at ? now()->diffInDays($c->last_activity_at) : now()->diffInDays($c->created_at),
]);
// Field jobs assigned today // Activities (limit 10) - cached
$fieldJobsAssignedToday = FieldJob::query() $activities = Cache::remember('dashboard:activities', $cacheMinutes * 60, function () {
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today) return Activity::query()
->select(['id','assigned_user_id','priority','assigned_at','created_at','contract_id']) ->with(['clientCase:id,uuid'])
->latest(DB::raw('COALESCE(assigned_at, created_at)')) ->latest()
->limit(15) ->limit(10)
->get(); ->get(['id', 'note', 'created_at', 'client_case_id', 'contract_id', 'action_id', 'decision_id'])
->map(fn ($a) => [
'id' => $a->id,
'note' => $a->note,
'created_at' => $a->created_at,
'client_case_id' => $a->client_case_id,
'client_case_uuid' => $a->clientCase?->uuid,
'contract_id' => $a->contract_id,
'action_id' => $a->action_id,
'decision_id' => $a->decision_id,
]);
});
// Imports in progress (queued / processing) // 7-day trends for field jobs - cached
$importsInProgress = Import::query() $trends = Cache::remember('dashboard:field_jobs_trends:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
->whereIn('status', ['queued','processing']) $start = now()->subDays(6)->startOfDay();
->latest('created_at') $end = now()->endOfDay();
->limit(10)
->get(['id','uuid','file_name','status','total_rows','imported_rows','valid_rows','invalid_rows','started_at'])
->map(fn($i) => [
'id' => $i->id,
'uuid' => $i->uuid,
'file_name' => $i->file_name,
'status' => $i->status,
'total_rows' => $i->total_rows,
'imported_rows' => $i->imported_rows,
'valid_rows' => $i->valid_rows,
'invalid_rows' => $i->invalid_rows,
'progress_pct' => $i->total_rows ? round(($i->imported_rows / max(1,$i->total_rows))*100,1) : null,
'started_at' => $i->started_at,
]);
// Active document templates summary (active versions) $dateKeys = collect(range(0, 6))
$activeTemplates = \App\Models\DocumentTemplate::query() ->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
->where('active', true)
->latest('updated_at')
->limit(10)
->get(['id','name','slug','version','updated_at']);
// System health (deferred) $fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
$queueBacklog = Schema::hasTable('jobs') ? DB::table('jobs')->count() : null; ->selectRaw("DATE(COALESCE(assigned_at, created_at) AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
$failedJobs = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : null; ->groupBy('d')
->pluck('c', 'd');
// Completed field jobs last 7 days
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
->whereBetween('completed_at', [$start, $end])
->selectRaw("DATE(completed_at AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
->groupBy('d')
->pluck('c', 'd');
return [
'field_jobs' => $dateKeys->map(fn ($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(),
'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
'labels' => $dateKeys,
];
});
// Field jobs assigned today - cached
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
return FieldJob::query()
->whereRaw('DATE(COALESCE(assigned_at, created_at)) = ?', [$today->toDateString()])
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
->with(['contract' => function ($q) {
$q->select('id', 'uuid', 'reference', 'client_case_id')
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
}])
->orderByRaw('COALESCE(assigned_at, created_at) DESC')
->limit(15)
->get()
->map(function ($fj) {
$contract = $fj->contract;
$segmentId = null;
if ($contract && method_exists($contract, 'segments')) {
$activeSeg = $contract->segments->first();
if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) {
$segmentId = $activeSeg->id;
}
}
if (! $contract) {
return null;
}
return [
'id' => $fj->id,
'priority' => $fj->priority,
'assigned_at' => $fj->assigned_at?->toIso8601String(),
'created_at' => $fj->created_at?->toIso8601String(),
'contract' => [
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'client_case_uuid' => optional($contract->clientCase)->uuid,
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
'segment_id' => $segmentId,
],
];
})
->filter()
->values();
});
// System health for timestamp
$recentActivity = Activity::query()->latest('created_at')->value('created_at'); $recentActivity = Activity::query()->latest('created_at')->value('created_at');
$lastActivityMinutes = null; $lastActivityMinutes = null;
if ($recentActivity) { if ($recentActivity) {
// diffInMinutes is absolute (non-negative) but guard anyway & cast to int
$lastActivityMinutes = (int) max(0, now()->diffInMinutes($recentActivity)); $lastActivityMinutes = (int) max(0, now()->diffInMinutes($recentActivity));
} }
$systemHealth = [ $systemHealth = [
'queue_backlog' => $queueBacklog,
'failed_jobs' => $failedJobs,
'last_activity_minutes' => $lastActivityMinutes, 'last_activity_minutes' => $lastActivityMinutes,
'last_activity_iso' => $recentActivity?->toIso8601String(), 'last_activity_iso' => $recentActivity?->toIso8601String(),
'generated_at' => now()->toIso8601String(), 'generated_at' => now()->toIso8601String(),
]; ];
return Inertia::render('Dashboard', [ return Inertia::render('Dashboard/Index', [
'kpis' => [ 'kpis' => [
'clients_total' => $clientsTotal, 'active_clients' => $activeClientsCount,
'clients_new_7d' => $clientsNew7d, 'active_contracts' => $activeContractsCount,
'field_jobs_today' => $fieldJobsToday, 'total_balance' => $totalBalance,
'documents_today' => $documentsToday, 'active_promises' => $activePromisesCount,
'active_imports' => $activeImports,
'active_contracts' => $activeContracts,
], ],
'trends' => $trends, 'trends' => $trends,
])->with([ // deferred props (Inertia v2 style) ])->with([
'activities' => fn () => $activities, 'activities' => fn () => $activities,
'systemHealth' => fn () => $systemHealth, 'systemHealth' => fn () => $systemHealth,
'staleCases' => fn () => $staleCases,
'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday, 'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday,
'importsInProgress' => fn () => $importsInProgress, 'smsStats' => function () use ($sms, $today, $cacheMinutes) {
'activeTemplates' => fn () => $activeTemplates, // SMS stats - cached
return Cache::remember('dashboard:sms_stats:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($sms, $today) {
$counts = SmsLog::query()
->whereDate('created_at', $today)
->selectRaw('profile_id, status, COUNT(*) as c')
->groupBy('profile_id', 'status')
->get()
->groupBy('profile_id')
->map(function ($rows) {
$map = [
'queued' => 0,
'sent' => 0,
'delivered' => 0,
'failed' => 0,
];
foreach ($rows as $r) {
$map[$r->status] = (int) $r->c;
}
$map['total'] = array_sum($map);
return $map;
});
$profiles = SmsProfile::query()
->orderBy('name')
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
try {
$balance = $sms->getCreditBalance($p);
} catch (\Throwable $e) {
$balance = '—';
}
$c = $counts->get($p->id) ?? ['queued' => 0, 'sent' => 0, 'delivered' => 0, 'failed' => 0, 'total' => 0];
return [
'id' => $p->id,
'name' => $p->name,
'active' => (bool) $p->active,
'balance' => $balance,
'today' => $c,
];
})->values();
});
},
]); ]);
} }
} }
-2
View File
@@ -2,8 +2,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Http\Request;
class DebtController extends Controller class DebtController extends Controller
{ {
// //
+165 -41
View File
@@ -25,56 +25,109 @@ public function index(Request $request)
optional($setting)->segment_id, optional($setting)->segment_id,
])->filter()->unique()->values(); ])->filter()->unique()->values();
$contracts = Contract::query() $search = $request->input('search');
->with(['clientCase.person', 'clientCase.client.person', 'type', 'account']) $searchAssigned = $request->input('search_assigned');
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) { $assignedUserId = $request->input('assigned_user_id');
$q->whereHas('segments', function ($sq) use ($segmentIds) { $unassignedClientUuids = $request->input('unassigned_client_uuids');
// Relation already filters on active pivots $assignedClientUuids = $request->input('assigned_client_uuids');
$sq->whereIn('segments.id', $segmentIds);
});
}, function ($q) {
// No segments configured on FieldJobSetting -> return none
$q->whereRaw('1 = 0');
})
->latest('id')
->limit(50)
->get();
// Mirror client onto the contract for simpler frontend access: c.client.person.full_name $unassignedContracts = Contract::query()
$contracts->each(function (Contract $contract): void { ->with(['clientCase.person.addresses', 'clientCase.client.person:id,uuid,full_name', 'type', 'account'])
if ($contract->relationLoaded('clientCase') && $contract->clientCase) { ->when($segmentIds->isNotEmpty(), fn($q) =>
$contract->setRelation('client', $contract->clientCase->client); $q->whereHas('segments', fn($rq) => $rq->whereIn('segments.id', $segmentIds)),
} fn($q) => $q->whereRaw('1 = 0')
}); )
->when( !empty($search), fn ($q) =>
$q->where(fn($sq) =>
$sq->where('reference', 'like', "%{$search}%")
->orWhereHas('clientCase.person', fn($psq) =>
$psq->where('full_name', 'ilike', "%{$search}%")
)
->orWhereHas('clientCase.person.addresses', fn ($ccpaq) =>
$ccpaq->where('address', 'ilike', "%{$search}")
)
)
)
->when(!empty($unassignedClientUuids) && is_array($unassignedClientUuids), fn ($q) =>
$q->whereHas('clientCase.client', fn($cq) =>
$cq->whereIn('uuid', $unassignedClientUuids)
)
)
->whereDoesntHave('fieldJobs', fn ($q) =>
$q->whereNull('completed_at')
->whereNull('cancelled_at')
)
->latest('id');
// Build active assignment map keyed by contract uuid for quicker UI checks $unassignedClients = $unassignedContracts->get()
$assignments = collect(); ->pluck('clientCase.client')
if ($contracts->isNotEmpty()) { ->filter()
$activeJobs = FieldJob::query() ->unique('id')
->whereIn('contract_id', $contracts->pluck('id')) ->values();
->whereNull('completed_at')
->whereNull('cancelled_at')
->with(['assignedUser:id,name', 'user:id,name', 'contract:id,uuid'])
->get();
$assignments = $activeJobs->mapWithKeys(function (FieldJob $job) {
return [ $assignedContracts = Contract::query()
optional($job->contract)->uuid => [ ->with(['clientCase.person.addresses', 'clientCase.client.person:id,uuid,full_name', 'type', 'account', 'lastFieldJobs', 'lastFieldJobs.assignedUser', 'lastFieldJobs.user'])
'assigned_to' => $job->assignedUser ? ['id' => $job->assignedUser->id, 'name' => $job->assignedUser->name] : null, ->when($segmentIds->isNotEmpty(), fn($q) =>
'assigned_by' => $job->user ? ['id' => $job->user->id, 'name' => $job->user->name] : null, $q->whereHas('segments', fn($rq) => $rq->whereIn('segments.id', $segmentIds)),
'assigned_at' => $job->assigned_at, fn($q) => $q->whereRaw('1 = 0')
], )
]; ->when( !empty($searchAssigned), fn ($q) =>
})->filter(); $q->where(fn($sq) =>
} $sq->where('reference', 'like', "%{$searchAssigned}%")
->orWhereHas('clientCase.person', fn($psq) =>
$psq->where('full_name', 'ilike', "%{$searchAssigned}%")
)
->orWhereHas('clientCase.person.addresses', fn ($ccpaq) =>
$ccpaq->where('address', 'ilike', "%{$searchAssigned}")
)
)
)
->when(!empty($assignedClientUuids) && is_array($assignedClientUuids), fn ($q) =>
$q->whereHas('clientCase.client', fn($cq) =>
$cq->whereIn('uuid', $assignedClientUuids)
)
)
->whereHas('lastFieldJobs', fn ($q) =>
$q->whereNull('completed_at')
->whereNull('cancelled_at')
->when($assignedUserId && $assignedUserId !== 'all', fn ($jq) =>
$jq->where('assigned_user_id', $assignedUserId))
)
->latest('id');
$assignedClients = $assignedContracts->get()
->pluck('clientCase.client')
->filter()
->unique('id')
->values();
$users = User::query()->orderBy('name')->get(['id', 'name']); $users = User::query()->orderBy('name')->get(['id', 'name']);
return Inertia::render('FieldJob/Index', [ return Inertia::render('FieldJob/Index', [
'setting' => $setting, 'setting' => $setting,
'contracts' => $contracts, 'unassignedContracts' => $unassignedContracts->paginate(
$request->input('per_page_contracts', 10),
['*'],
'page_contracts',
$request->input('page_contracts', 1)
),
'assignedContracts' => $assignedContracts->paginate(
$request->input('per_page_assignments', 10),
['*'],
'page_assignments',
$request->input('page_assignments', 1)
),
'unassignedClients' => $unassignedClients,
'assignedClients' => $assignedClients,
'users' => $users, 'users' => $users,
'assignments' => $assignments, 'filters' => [
'search' => $search,
'search_assigned' => $searchAssigned,
'assigned_user_id' => $assignedUserId,
'unassigned_client_uuids' => $unassignedClientUuids,
'assigned_client_uuids' => $assignedClientUuids,
],
]); ]);
} }
@@ -132,6 +185,77 @@ public function assign(Request $request)
} }
/**
* Bulk assign multiple contracts to a single user.
*/
public function assignBulk(Request $request)
{
$data = $request->validate([
'contract_uuids' => 'required|array|min:1',
'contract_uuids.*' => 'required|string|distinct|exists:contracts,uuid',
'assigned_user_id' => 'required|integer|exists:users,id',
]);
try {
DB::transaction(function () use ($data) {
$setting = FieldJobSetting::query()->latest('id')->first();
if (! $setting) {
throw new Exception('No Field Job Setting found. Create one in Settings → Field Job Settings.');
}
if (! ($setting->action_id && $setting->assign_decision_id)) {
throw new Exception('The current Field Job Setting is missing an action or assign decision. Please update it in Settings → Field Job Settings.');
}
$assigneeName = User::query()->where('id', $data['assigned_user_id'])->value('name');
$noteBase = 'Terensko opravilo dodeljeno'.($assigneeName ? ' uporabniku '.$assigneeName : '');
// Load all contracts in one query
$contracts = Contract::query()->whereIn('uuid', $data['contract_uuids'])->get();
foreach ($contracts as $contract) {
// Skip if already has an active job
$hasActive = FieldJob::query()
->where('contract_id', $contract->id)
->whereNull('completed_at')
->whereNull('cancelled_at')
->exists();
if ($hasActive) {
continue;
}
$job = FieldJob::create([
'field_job_setting_id' => $setting->id,
'assigned_user_id' => $data['assigned_user_id'],
'contract_id' => $contract->id,
'assigned_at' => now(),
]);
Activity::create([
'due_date' => null,
'amount' => null,
'note' => $noteBase,
'action_id' => $setting->action_id,
'decision_id' => $setting->assign_decision_id,
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
]);
// Move contract to the configured segment for field jobs
$job->moveContractToSegment($setting->segment_id);
}
});
return back()->with('success', 'Field jobs assigned.');
} catch (QueryException $e) {
return back()->withErrors(['database' => 'Database error: '.$e->getMessage()]);
} catch (Exception $e) {
return back()->withErrors(['error' => 'Error: '.$e->getMessage()]);
}
}
public function cancel(Request $request) public function cancel(Request $request)
{ {
$data = $request->validate([ $data = $request->validate([
+270 -7
View File
@@ -9,6 +9,7 @@
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\ImportSimulationServiceV2;
use App\Services\ImportProcessor; use App\Services\ImportProcessor;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -21,14 +22,35 @@ class ImportController extends Controller
// List imports (paginated) // List imports (paginated)
public function index(Request $request) public function index(Request $request)
{ {
$paginator = Import::query() $query = Import::query()
->with([ ->with([
'client:id,uuid,person_id', 'client:id,uuid,person_id',
'client.person:id,uuid,full_name', 'client.person:id,uuid,full_name',
'template:id,name', 'template:id,name',
]) ])
->orderByDesc('created_at') ->orderByDesc('created_at');
->paginate(15);
// Apply search filter
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('original_name', 'LIKE', "%{$search}%")
->orWhere('status', 'LIKE', "%{$search}%")
->orWhereHas('client.person', function ($q) use ($search) {
$q->where('full_name', 'LIKE', "%{$search}%");
})
->orWhereHas('template', function ($q) use ($search) {
$q->where('name', 'LIKE', "%{$search}%");
});
});
}
// Get per_page from request, default to 25
$perPage = (int) $request->input('per_page', 25);
if ($perPage < 1 || $perPage > 100) {
$perPage = 25;
}
$paginator = $query->paginate($perPage);
$imports = [ $imports = [
'data' => $paginator->items(), 'data' => $paginator->items(),
@@ -42,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(),
@@ -146,6 +169,7 @@ public function store(Request $request)
'size' => $file->getSize(), 'size' => $file->getSize(),
'sheet_name' => $validated['sheet_name'] ?? null, 'sheet_name' => $validated['sheet_name'] ?? null,
'status' => 'uploaded', 'status' => 'uploaded',
'show_missing' => false,
'meta' => [ 'meta' => [
'has_header' => $validated['has_header'] ?? true, 'has_header' => $validated['has_header'] ?? true,
], ],
@@ -155,6 +179,7 @@ public function store(Request $request)
'id' => $import->id, 'id' => $import->id,
'uuid' => $import->uuid, 'uuid' => $import->uuid,
'status' => $import->status, 'status' => $import->status,
'show_missing' => (bool) ($import->show_missing ?? false),
]); ]);
} }
@@ -162,9 +187,25 @@ public function store(Request $request)
public function process(Import $import, Request $request, ImportProcessor $processor) public function process(Import $import, Request $request, ImportProcessor $processor)
{ {
$import->update(['status' => 'validating', 'started_at' => now()]); $import->update(['status' => 'validating', 'started_at' => now()]);
$result = $processor->process($import, user: $request->user());
return response()->json($result); try {
$result = $processor->process($import, user: $request->user());
return response()->json($result);
} catch (\Throwable $e) {
\Log::error('Import processing failed', [
'import_id' => $import->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$import->update(['status' => 'failed']);
return response()->json([
'success' => false,
'message' => 'Import processing failed: '.$e->getMessage(),
], 500);
}
} }
// Analyze the uploaded file and return column headers or positional indices // Analyze the uploaded file and return column headers or positional indices
@@ -354,6 +395,125 @@ public function getMappings(Import $import)
return response()->json(['mappings' => $rows]); return response()->json(['mappings' => $rows]);
} }
/**
* List active, non-archived contracts for the import's client that are NOT present
* in the processed import file (based on mapped contract.reference values).
* Only available when contract.reference mapping apply_mode is 'keyref'.
*/
public function missingContracts(Import $import)
{
// Ensure client context is available
if (empty($import->client_id)) {
return response()->json(['error' => 'Import has no client bound.'], 422);
}
// Respect optional feature flag on import
if (! (bool) ($import->show_missing ?? false)) {
return response()->json(['error' => 'Missing contracts listing is disabled for this import.'], 422);
}
// Check that this import's mappings set contract.reference to keyref mode
$mappings = \DB::table('import_mappings')
->where('import_id', $import->id)
->get(['target_field', 'apply_mode']);
$isKeyref = false;
foreach ($mappings as $map) {
$tf = strtolower((string) ($map->target_field ?? ''));
$am = strtolower((string) ($map->apply_mode ?? ''));
if (in_array($tf, ['contract.reference', 'contracts.reference'], true) && $am === 'keyref') {
$isKeyref = true;
break;
}
}
if (! $isKeyref) {
return response()->json(['error' => 'Missing contracts are only available for keyref mapping on contract.reference.'], 422);
}
// Collect referenced contract references from processed rows
$present = [];
foreach (\App\Models\ImportRow::query()->where('import_id', $import->id)->get(['mapped_data']) as $row) {
$md = $row->mapped_data ?? [];
if (is_array($md) && isset($md['contract']['reference'])) {
$ref = (string) $md['contract']['reference'];
if ($ref !== '') {
$present[] = preg_replace('/\s+/', '', trim($ref));
}
}
}
$present = array_values(array_unique(array_filter($present)));
// Query active, non-archived contracts for this client that were not in import
// Include person full_name (owner of the client case) and aggregate active accounts' balance_amount
$contractsQ = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->join('person', 'person.id', '=', 'client_cases.person_id')
->leftJoin('accounts', function ($join) {
$join->on('accounts.contract_id', '=', 'contracts.id')
->where('accounts.active', 1);
})
->where('client_cases.client_id', $import->client_id)
->where('contracts.active', 1)
->whereNull('contracts.deleted_at')
// Exclude contracts that have any ACTIVE segment marked as excluded
->whereNotExists(function ($sq) {
$sq->select(\DB::raw(1))
->from('contract_segment')
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true)
->where('segments.exclude', true);
})
->when(count($present) > 0, function ($q) use ($present) {
$q->whereNotIn('contracts.reference', $present);
})
->groupBy('contracts.uuid', 'contracts.reference', 'client_cases.uuid', 'person.full_name')
->orderBy('contracts.reference')
->get([
'contracts.uuid as uuid',
'contracts.reference as reference',
'client_cases.uuid as case_uuid',
'person.full_name as full_name',
\DB::raw('COALESCE(SUM(accounts.balance_amount), 0) as balance_amount'),
]);
return response()->json([
'missing' => $contractsQ,
'count' => $contractsQ->count(),
]);
}
/**
* Update import options (e.g., booleans like show_missing, reactivate) from the UI.
*/
public function updateOptions(Request $request, Import $import)
{
$data = $request->validate([
'show_missing' => 'nullable|boolean',
'reactivate' => 'nullable|boolean',
]);
$payload = [];
if (array_key_exists('show_missing', $data)) {
$payload['show_missing'] = (bool) $data['show_missing'];
}
if (array_key_exists('reactivate', $data)) {
$payload['reactivate'] = (bool) $data['reactivate'];
}
if (! empty($payload)) {
$import->update($payload);
}
return response()->json([
'ok' => true,
'import' => [
'id' => $import->id,
'uuid' => $import->uuid,
'show_missing' => (bool) ($import->show_missing ?? false),
'reactivate' => (bool) ($import->reactivate ?? false),
],
]);
}
// Fetch recent import events (logs) for an import // Fetch recent import events (logs) for an import
public function getEvents(Import $import) public function getEvents(Import $import)
{ {
@@ -368,6 +528,90 @@ public function getEvents(Import $import)
return response()->json(['events' => $events]); return response()->json(['events' => $events]);
} }
// List unresolved keyref contract rows (based on events containing keyref-not-found)
public function missingKeyrefRows(Import $import)
{
// Identify row IDs from events. Prefer specific event key, fallback to message pattern
$rowIds = ImportEvent::query()
->where('import_id', $import->id)
->where(function ($q) {
$q->where('event', 'contract_keyref_not_found')
->orWhereRaw('LOWER(message) LIKE ?', ['%keyref%not found%']);
})
->whereNotNull('import_row_id')
->pluck('import_row_id')
->filter()
->unique()
->values();
if ($rowIds->isEmpty()) {
return response()->json([
'columns' => (array) ($import->meta['columns'] ?? []),
'rows' => [],
]);
}
$rows = \App\Models\ImportRow::query()
->where('import_id', $import->id)
->whereIn('id', $rowIds)
->orderBy('row_number')
->get(['id', 'row_number', 'raw_data']);
$columns = (array) ($import->meta['columns'] ?? []);
// If no stored header, derive from first row raw_data keys
if (empty($columns)) {
$first = $rows->first();
if ($first && is_array($first->raw_data)) {
$columns = array_keys($first->raw_data);
}
}
// Normalize each row to ordered array by $columns
$dataRows = [];
foreach ($rows as $r) {
$line = [];
foreach ($columns as $col) {
$line[] = (string) ($r->raw_data[$col] ?? '');
}
$dataRows[] = [
'id' => $r->id,
'row_number' => $r->row_number,
'values' => $line,
];
}
return response()->json([
'columns' => $columns,
'rows' => $dataRows,
]);
}
// Export unresolved keyref rows as CSV (includes header if available)
public function exportMissingKeyrefCsv(Import $import)
{
$json = $this->missingKeyrefRows($import)->getData(true);
$columns = $json['columns'] ?? [];
$rows = $json['rows'] ?? [];
$fh = fopen('php://temp', 'r+');
if (! empty($columns)) {
fputcsv($fh, $columns);
}
foreach ($rows as $r) {
fputcsv($fh, $r['values'] ?? []);
}
rewind($fh);
$csv = stream_get_contents($fh);
fclose($fh);
$filename = 'missing-keyref-rows-'.$import->id.'.csv';
return response($csv, 200, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
]);
}
// Preview (up to N) raw CSV rows for an import for mapping review // Preview (up to N) raw CSV rows for an import for mapping review
public function preview(Import $import, Request $request) public function preview(Import $import, Request $request)
{ {
@@ -468,6 +712,8 @@ public function simulatePayments(Import $import, Request $request)
* using the first N rows and current saved mappings. Works for both payments and non-payments * using the first N rows and current saved mappings. Works for both payments and non-payments
* 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.
*
* @return \Illuminate\Http\JsonResponse
*/ */
public function simulate(Import $import, Request $request) public function simulate(Import $import, Request $request)
{ {
@@ -478,7 +724,7 @@ public function simulate(Import $import, Request $request)
$limit = (int) ($validated['limit'] ?? 100); $limit = (int) ($validated['limit'] ?? 100);
$verbose = (bool) ($validated['verbose'] ?? false); $verbose = (bool) ($validated['verbose'] ?? false);
$service = app(\App\Services\ImportSimulationService::class); $service = app(ImportSimulationServiceV2::class);
$result = $service->simulate($import, $limit, $verbose); $result = $service->simulate($import, $limit, $verbose);
return response()->json($result); return response()->json($result);
@@ -533,6 +779,8 @@ public function show(Import $import)
'client_id' => $import->client_id, 'client_id' => $import->client_id,
'client_uuid' => optional($client)->uuid, 'client_uuid' => optional($client)->uuid,
'import_template_id' => $import->import_template_id, 'import_template_id' => $import->import_template_id,
'show_missing' => (bool) ($import->show_missing ?? false),
'reactivate' => (bool) ($import->reactivate ?? false),
'total_rows' => $import->total_rows, 'total_rows' => $import->total_rows,
'imported_rows' => $import->imported_rows, 'imported_rows' => $import->imported_rows,
'invalid_rows' => $import->invalid_rows, 'invalid_rows' => $import->invalid_rows,
@@ -578,6 +826,21 @@ public function destroy(Request $request, Import $import)
$import->delete(); $import->delete();
return back()->with(['ok' => true]); 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);
} }
} }
@@ -58,6 +58,13 @@ public function index()
'fields' => ['reference', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'], 'fields' => ['reference', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'],
'ui' => ['order' => 6], 'ui' => ['order' => 6],
], ],
[
'key' => 'activities',
'canonical_root' => 'activity',
'label' => 'Activities',
'fields' => ['note', 'due_date', 'amount', 'action_id', 'decision_id', 'contract_id', 'client_case_id', 'user_id'],
'ui' => ['order' => 7],
],
]); ]);
} else { } else {
// Ensure fields are arrays for frontend consumption // Ensure fields are arrays for frontend consumption
+230 -11
View File
@@ -23,6 +23,16 @@ public function index()
->orderBy('name') ->orderBy('name')
->get(); ->get();
// Preload options for import mapping
$clients = Client::query()
->join('person', 'person.id', '=', 'clients.person_id')
->orderBy('person.full_name')
->get(['clients.uuid', DB::raw('person.full_name as name')]);
$segments = Segment::query()->orderBy('name')->get(['id', 'name']);
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
$actions = Action::query()->orderBy('name')->get(['id', 'name']);
return Inertia::render('Imports/Templates/Index', [ return Inertia::render('Imports/Templates/Index', [
'templates' => $templates->map(fn ($t) => [ 'templates' => $templates->map(fn ($t) => [
'uuid' => $t->uuid, 'uuid' => $t->uuid,
@@ -35,6 +45,10 @@ public function index()
'name' => $t->client->person?->full_name, 'name' => $t->client->person?->full_name,
] : null, ] : null,
]), ]),
'clients' => $clients,
'segments' => $segments,
'decisions' => $decisions,
'actions' => $actions,
]); ]);
} }
@@ -111,10 +125,10 @@ public function store(Request $request)
'is_active' => 'boolean', 'is_active' => 'boolean',
'reactivate' => 'boolean', 'reactivate' => 'boolean',
'entities' => 'nullable|array', 'entities' => 'nullable|array',
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments', 'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'mappings' => 'array', 'mappings' => 'array',
'mappings.*.source_column' => 'required|string', 'mappings.*.source_column' => 'required|string',
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments', 'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'mappings.*.target_field' => 'nullable|string', 'mappings.*.target_field' => 'nullable|string',
'mappings.*.transform' => 'nullable|string|max:50', 'mappings.*.transform' => 'nullable|string|max:50',
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref',
@@ -124,7 +138,11 @@ public function store(Request $request)
'meta.segment_id' => 'nullable|integer|exists:segments,id', 'meta.segment_id' => 'nullable|integer|exists:segments,id',
'meta.decision_id' => 'nullable|integer|exists:decisions,id', 'meta.decision_id' => 'nullable|integer|exists:decisions,id',
'meta.action_id' => 'nullable|integer|exists:actions,id', 'meta.action_id' => 'nullable|integer|exists:actions,id',
'meta.activity_action_id' => 'nullable|integer|exists:actions,id',
'meta.activity_decision_id' => 'nullable|integer|exists:decisions,id',
'meta.activity_created_at' => 'nullable|date',
'meta.payments_import' => 'nullable|boolean', 'meta.payments_import' => 'nullable|boolean',
'meta.history_import' => 'nullable|boolean',
'meta.contract_key_mode' => 'nullable|string|in:reference', 'meta.contract_key_mode' => 'nullable|string|in:reference',
])->validate(); ])->validate();
@@ -142,7 +160,28 @@ public function store(Request $request)
$template = null; $template = null;
DB::transaction(function () use (&$template, $request, $data) { DB::transaction(function () use (&$template, $request, $data) {
$paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false); $paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false);
$historyImport = (bool) (data_get($data, 'meta.history_import') ?? false);
$entities = $data['entities'] ?? []; $entities = $data['entities'] ?? [];
if ($historyImport) {
$paymentsImport = false; // history import cannot be combined with payments mode
$allowedHistoryEntities = ['person', 'person_addresses', 'person_phones', 'contracts', 'activities', 'client_cases'];
$entities = array_values(array_intersect($entities, $allowedHistoryEntities));
// If contracts are present, ensure accounts are included implicitly for reference consistency
if (in_array('contracts', $entities, true) && ! in_array('accounts', $entities, true)) {
$entities[] = 'accounts';
}
// Reject mappings that target disallowed entities for history import
$disallowedMappings = collect($data['mappings'] ?? [])->filter(function ($m) use ($allowedHistoryEntities) {
if (empty($m['entity'])) {
return false;
}
return ! in_array($m['entity'], $allowedHistoryEntities, true);
});
if ($disallowedMappings->isNotEmpty()) {
abort(422, 'History import only allows entities: person, person_addresses, person_phones, contracts, activities, client_cases. Remove other mapping entities.');
}
}
if ($paymentsImport) { if ($paymentsImport) {
$entities = ['contracts', 'accounts', 'payments']; $entities = ['contracts', 'accounts', 'payments'];
} }
@@ -162,7 +201,11 @@ public function store(Request $request)
'segment_id' => data_get($data, 'meta.segment_id'), 'segment_id' => data_get($data, 'meta.segment_id'),
'decision_id' => data_get($data, 'meta.decision_id'), 'decision_id' => data_get($data, 'meta.decision_id'),
'action_id' => data_get($data, 'meta.action_id'), 'action_id' => data_get($data, 'meta.action_id'),
'activity_action_id' => data_get($data, 'meta.activity_action_id'),
'activity_decision_id' => data_get($data, 'meta.activity_decision_id'),
'activity_created_at' => data_get($data, 'meta.activity_created_at'),
'payments_import' => $paymentsImport ?: null, 'payments_import' => $paymentsImport ?: null,
'history_import' => $historyImport ?: null,
'contract_key_mode' => data_get($data, 'meta.contract_key_mode'), 'contract_key_mode' => data_get($data, 'meta.contract_key_mode'),
], fn ($v) => ! is_null($v) && $v !== ''), ], fn ($v) => ! is_null($v) && $v !== ''),
]); ]);
@@ -244,7 +287,7 @@ public function addMapping(Request $request, ImportTemplate $template)
} }
$data = validator($raw, [ $data = validator($raw, [
'source_column' => 'required|string', 'source_column' => 'required|string',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments', 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'target_field' => 'nullable|string', 'target_field' => 'nullable|string',
'transform' => 'nullable|string|in:trim,upper,lower', 'transform' => 'nullable|string|in:trim,upper,lower',
'apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
@@ -314,7 +357,11 @@ public function update(Request $request, ImportTemplate $template)
'meta.segment_id' => 'nullable|integer|exists:segments,id', 'meta.segment_id' => 'nullable|integer|exists:segments,id',
'meta.decision_id' => 'nullable|integer|exists:decisions,id', 'meta.decision_id' => 'nullable|integer|exists:decisions,id',
'meta.action_id' => 'nullable|integer|exists:actions,id', 'meta.action_id' => 'nullable|integer|exists:actions,id',
'meta.activity_action_id' => 'nullable|integer|exists:actions,id',
'meta.activity_decision_id' => 'nullable|integer|exists:decisions,id',
'meta.activity_created_at' => 'nullable|date',
'meta.payments_import' => 'nullable|boolean', 'meta.payments_import' => 'nullable|boolean',
'meta.history_import' => 'nullable|boolean',
'meta.contract_key_mode' => 'nullable|string|in:reference', 'meta.contract_key_mode' => 'nullable|string|in:reference',
])->validate(); ])->validate();
@@ -342,6 +389,11 @@ public function update(Request $request, ImportTemplate $template)
unset($newMeta[$k]); unset($newMeta[$k]);
} }
} }
foreach (['activity_action_id', 'activity_decision_id', 'activity_created_at'] as $k) {
if (array_key_exists($k, $newMeta) && ($newMeta[$k] === '' || is_null($newMeta[$k]))) {
unset($newMeta[$k]);
}
}
} }
// Finalize meta (ensure payments entities forced if enabled) // Finalize meta (ensure payments entities forced if enabled)
@@ -349,6 +401,20 @@ public function update(Request $request, ImportTemplate $template)
if (! empty($finalMeta['payments_import'])) { if (! empty($finalMeta['payments_import'])) {
$finalMeta['entities'] = ['contracts', 'accounts', 'payments']; $finalMeta['entities'] = ['contracts', 'accounts', 'payments'];
} }
if (! empty($finalMeta['history_import'])) {
$finalMeta['payments_import'] = false;
$allowedHistoryEntities = ['person', 'person_addresses', 'person_phones', 'contracts', 'activities', 'client_cases'];
$finalMeta['entities'] = array_values(array_intersect($finalMeta['entities'] ?? [], $allowedHistoryEntities));
if (in_array('contracts', $finalMeta['entities'] ?? [], true) && ! in_array('accounts', $finalMeta['entities'] ?? [], true)) {
$finalMeta['entities'][] = 'accounts';
}
}
if (in_array('activities', $finalMeta['entities'] ?? [], true)) {
if (empty($finalMeta['activity_action_id']) || empty($finalMeta['activity_decision_id'])) {
return back()->withErrors(['meta.activity_action_id' => 'Activities import requires selecting both a default action and decision.'])->withInput();
}
}
$update = [ $update = [
'name' => $data['name'], 'name' => $data['name'],
@@ -381,10 +447,12 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
} }
$data = validator($raw, [ $data = validator($raw, [
'sources' => 'required|string', // comma and/or newline separated 'sources' => 'required|string', // comma and/or newline separated
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments', 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'default_field' => 'nullable|string', // if provided, used as the field name for all entries 'default_field' => 'nullable|string', // if provided, used as the field name for all entries
'apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
'transform' => 'nullable|string|in:trim,upper,lower', 'transform' => 'nullable|string|in:trim,upper,lower',
'options' => 'nullable|array',
'group' => 'nullable|string|max:50', // convenience: will be wrapped into options.group
])->validate(); ])->validate();
// Accept commas, semicolons, and newlines; strip surrounding quotes/apostrophes and whitespace // Accept commas, semicolons, and newlines; strip surrounding quotes/apostrophes and whitespace
@@ -408,9 +476,18 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
$entity = $data['entity'] ?? null; $entity = $data['entity'] ?? null;
$defaultField = $data['default_field'] ?? null; // allows forcing a specific field for all $defaultField = $data['default_field'] ?? null; // allows forcing a specific field for all
// Build options payload once
$opts = [];
if (isset($data['options']) && is_array($data['options'])) {
$opts = $data['options'];
}
if (! empty($data['group'])) {
$opts['group'] = (string) $data['group'];
}
$created = 0; $created = 0;
$updated = 0; $updated = 0;
DB::transaction(function () use ($template, $list, $apply, $transform, $entity, $defaultField, $basePosition, &$created, &$updated) { DB::transaction(function () use ($template, $list, $apply, $transform, $entity, $defaultField, $basePosition, $opts, &$created, &$updated) {
foreach ($list as $idx => $source) { foreach ($list as $idx => $source) {
$targetField = null; $targetField = null;
if ($defaultField) { if ($defaultField) {
@@ -429,7 +506,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
'entity' => $entity ?? $existing->entity, 'entity' => $entity ?? $existing->entity,
'transform' => $transform ?? $existing->transform, 'transform' => $transform ?? $existing->transform,
'apply_mode' => $apply ?? $existing->apply_mode ?? 'both', 'apply_mode' => $apply ?? $existing->apply_mode ?? 'both',
'options' => $existing->options, 'options' => empty($opts) ? $existing->options : $opts,
// keep existing position // keep existing position
]); ]);
$updated++; $updated++;
@@ -441,7 +518,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
'target_field' => $targetField, 'target_field' => $targetField,
'transform' => $transform, 'transform' => $transform,
'apply_mode' => $apply, 'apply_mode' => $apply,
'options' => null, 'options' => empty($opts) ? null : $opts,
'position' => $basePosition + $idx + 1, 'position' => $basePosition + $idx + 1,
]); ]);
$created++; $created++;
@@ -477,13 +554,14 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
} }
$data = validator($raw, [ $data = validator($raw, [
'source_column' => 'required|string', 'source_column' => 'required|string',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments', 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments',
'target_field' => 'nullable|string', 'target_field' => 'nullable|string',
'transform' => 'nullable|string|in:trim,upper,lower', 'transform' => 'nullable|string|in:trim,upper,lower',
'apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
'options' => 'nullable|array', 'options' => 'nullable|array',
'position' => 'nullable|integer', 'position' => 'nullable|integer',
])->validate(); ])->validate();
$mapping->update([ $mapping->update([
'source_column' => $data['source_column'], 'source_column' => $data['source_column'],
'entity' => $data['entity'] ?? null, 'entity' => $data['entity'] ?? null,
@@ -494,8 +572,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
'position' => $data['position'] ?? $mapping->position, 'position' => $data['position'] ?? $mapping->position,
]); ]);
return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) return back()->with('success', 'Mapping updated');
->with('success', 'Mapping updated');
} }
// Delete a mapping // Delete a mapping
@@ -546,6 +623,10 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
$rows = $template->mappings()->orderBy('position')->get(); $rows = $template->mappings()->orderBy('position')->get();
foreach ($rows as $row) { foreach ($rows as $row) {
$options = $row->options;
if (is_array($options) || $options instanceof \JsonSerializable || $options instanceof \stdClass) {
$options = json_encode($options);
}
\DB::table('import_mappings')->insert([ \DB::table('import_mappings')->insert([
'import_id' => $import->id, 'import_id' => $import->id,
'entity' => $row->entity, 'entity' => $row->entity,
@@ -553,7 +634,7 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
'target_field' => $row->target_field, 'target_field' => $row->target_field,
'transform' => $row->transform, 'transform' => $row->transform,
'apply_mode' => $row->apply_mode ?? 'both', 'apply_mode' => $row->apply_mode ?? 'both',
'options' => $row->options, 'options' => $options,
'position' => $row->position ?? null, 'position' => $row->position ?? null,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
@@ -568,11 +649,15 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
'segment_id' => $tplMeta['segment_id'] ?? null, 'segment_id' => $tplMeta['segment_id'] ?? null,
'decision_id' => $tplMeta['decision_id'] ?? null, 'decision_id' => $tplMeta['decision_id'] ?? null,
'action_id' => $tplMeta['action_id'] ?? null, 'action_id' => $tplMeta['action_id'] ?? null,
'activity_action_id' => $tplMeta['activity_action_id'] ?? null,
'activity_decision_id' => $tplMeta['activity_decision_id'] ?? null,
'activity_created_at' => $tplMeta['activity_created_at'] ?? null,
'template_name' => $template->name, 'template_name' => $template->name,
], fn ($v) => ! is_null($v) && $v !== '')); ], fn ($v) => ! is_null($v) && $v !== ''));
$import->update([ $import->update([
'import_template_id' => $template->id, 'import_template_id' => $template->id,
'reactivate' => $template->reactivate,
'meta' => $merged, 'meta' => $merged,
]); ]);
}); });
@@ -594,4 +679,138 @@ public function destroy(ImportTemplate $template)
return redirect()->route('importTemplates.index')->with('success', 'Template deleted'); return redirect()->route('importTemplates.index')->with('success', 'Template deleted');
} }
// Export template as JSON file
public function export(ImportTemplate $template)
{
$template->load('mappings');
$data = [
'name' => $template->name,
'description' => $template->description,
'source_type' => $template->source_type,
'default_record_type' => $template->default_record_type,
'sample_headers' => $template->sample_headers,
'is_active' => $template->is_active,
'reactivate' => $template->reactivate,
'meta' => $template->meta,
'mappings' => $template->mappings->map(fn ($m) => [
'source_column' => $m->source_column,
'entity' => $m->entity,
'target_field' => $m->target_field,
'transform' => $m->transform,
'apply_mode' => $m->apply_mode,
'options' => $m->options,
'position' => $m->position,
])->values()->toArray(),
];
$filename = Str::slug($template->name).'-'.now()->format('Y-m-d').'.json';
return response()->json($data)
->header('Content-Disposition', 'attachment; filename="'.$filename.'"');
}
// Import template from JSON file
public function import(Request $request)
{
$data = $request->validate([
'file' => 'required|file|mimes:json,txt|max:10240',
'client_uuid' => 'nullable|string|exists:clients,uuid',
'segment_id' => 'nullable|integer|exists:segments,id',
'decision_id' => 'nullable|integer|exists:decisions,id',
'action_id' => 'nullable|integer|exists:actions,id',
'activity_action_id' => 'nullable|integer|exists:actions,id',
'activity_decision_id' => 'nullable|integer|exists:decisions,id',
]);
$file = $request->file('file');
$contents = file_get_contents($file->getRealPath());
$json = json_decode($contents, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return back()->withErrors(['file' => 'Invalid JSON file']);
}
// Validate structure
$validator = validator($json, [
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:255',
'source_type' => 'required|string|in:csv,xml,xls,xlsx,json',
'default_record_type' => 'nullable|string|max:50',
'sample_headers' => 'nullable|array',
'is_active' => 'nullable|boolean',
'reactivate' => 'nullable|boolean',
'meta' => 'nullable|array',
'mappings' => 'nullable|array',
'mappings.*.source_column' => 'required|string',
'mappings.*.entity' => 'nullable|string',
'mappings.*.target_field' => 'nullable|string',
'mappings.*.transform' => 'nullable|string',
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref',
'mappings.*.options' => 'nullable|array',
'mappings.*.position' => 'nullable|integer',
]);
if ($validator->fails()) {
return back()->withErrors($validator)->withInput();
}
$clientId = null;
if (! empty($data['client_uuid'])) {
$clientId = Client::where('uuid', $data['client_uuid'])->value('id');
}
// Replace IDs in meta if provided
$meta = $json['meta'] ?? [];
if (! empty($data['segment_id'])) {
$meta['segment_id'] = (int) $data['segment_id'];
}
if (! empty($data['decision_id'])) {
$meta['decision_id'] = (int) $data['decision_id'];
}
if (! empty($data['action_id'])) {
$meta['action_id'] = (int) $data['action_id'];
}
if (! empty($data['activity_action_id'])) {
$meta['activity_action_id'] = (int) $data['activity_action_id'];
}
if (! empty($data['activity_decision_id'])) {
$meta['activity_decision_id'] = (int) $data['activity_decision_id'];
}
$template = null;
DB::transaction(function () use (&$template, $request, $json, $clientId, $meta) {
$template = ImportTemplate::create([
'uuid' => (string) Str::uuid(),
'name' => $json['name'],
'description' => $json['description'] ?? null,
'source_type' => $json['source_type'],
'default_record_type' => $json['default_record_type'] ?? null,
'sample_headers' => $json['sample_headers'] ?? null,
'user_id' => $request->user()?->id,
'client_id' => $clientId,
'is_active' => $json['is_active'] ?? true,
'reactivate' => $json['reactivate'] ?? false,
'meta' => $meta,
]);
foreach (($json['mappings'] ?? []) as $m) {
ImportTemplateMapping::create([
'import_template_id' => $template->id,
'entity' => $m['entity'] ?? null,
'source_column' => $m['source_column'],
'target_field' => $m['target_field'] ?? null,
'transform' => $m['transform'] ?? null,
'apply_mode' => $m['apply_mode'] ?? 'both',
'options' => $m['options'] ?? null,
'position' => $m['position'] ?? null,
]);
}
});
return redirect()
->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Template imported successfully');
}
} }
@@ -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.');
}
}
@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers;
use App\Models\Activity;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Contract;
use Illuminate\Http\Request;
use Inertia\Inertia;
class NotificationController extends Controller
{
public function unread(Request $request)
{
$user = $request->user();
if (! $user) {
abort(403);
}
$today = now()->toDateString();
$perPage = max(1, min(100, (int) $request->integer('per_page', 15)));
$search = trim((string) $request->input('search', ''));
$clientUuid = trim((string) $request->input('client', ''));
$clientId = null;
$clientCaseIdsForFilter = collect();
if ($clientUuid !== '') {
$clientId = Client::query()->where('uuid', $clientUuid)->value('id');
if ($clientId) {
$clientCaseIdsForFilter = ClientCase::query()->where('client_id', $clientId)->pluck('id');
}
}
$query = Activity::query()
->select(['id', 'due_date', 'amount', 'contract_id', 'client_case_id', 'created_at'])
->whereNotNull('due_date')
->whereDate('due_date', '<=', $today)
// Exclude activities that have been marked as read by this user
->whereNotExists(function ($q) use ($user, $today) {
$q->select(\DB::raw(1))
->from('activity_notification_reads')
->whereColumn('activity_notification_reads.activity_id', 'activities.id')
->where('activity_notification_reads.user_id', $user->id)
->whereDate('activity_notification_reads.due_date', '<=', $today)
->whereNotNull('activity_notification_reads.read_at');
})
->when($clientCaseIdsForFilter->isNotEmpty(), function ($q) use ($clientCaseIdsForFilter) {
// Filter by clients: activities directly on any of the client's cases OR via contracts under those cases
$q->where(function ($qq) use ($clientCaseIdsForFilter) {
$qq->whereIn('activities.client_case_id', $clientCaseIdsForFilter)
->orWhereIn('activities.contract_id', Contract::query()
->select('id')
->whereIn('client_case_id', $clientCaseIdsForFilter)
);
});
})
// allow simple search by contract reference or person name
->when($search !== '', function ($q) use ($search) {
$s = mb_strtolower($search);
$q->leftJoin('contracts', 'contracts.id', '=', 'activities.contract_id')
->leftJoin('client_cases', 'client_cases.id', '=', 'activities.client_case_id')
->leftJoin('person', 'person.id', '=', 'client_cases.person_id')
->where(function ($qq) use ($s) {
$qq->whereRaw('LOWER(COALESCE(contracts.reference, \'\')) LIKE ?', ['%'.$s.'%'])
->orWhereRaw('LOWER(COALESCE(person.full_name, \'\')) LIKE ?', ['%'.$s.'%']);
});
})
->with([
'contract' => function ($q) {
$q->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.client_case_id'])
->with([
'clientCase' => function ($qq) {
$qq->select(['client_cases.id', 'client_cases.uuid', 'client_cases.client_id'])
->with([
'client' => function ($qqq) {
$qqq->select(['clients.id', 'clients.uuid', 'clients.person_id'])
->with([
'person' => function ($qqqq) {
$qqqq->select(['person.id', 'person.full_name']);
},
]);
},
]);
},
'account' => function ($qq) {
$qq->select(['accounts.id', 'accounts.contract_id', 'accounts.balance_amount', 'accounts.initial_amount']);
},
]);
},
'clientCase' => function ($q) {
$q->select(['client_cases.id', 'client_cases.uuid', 'client_cases.person_id', 'client_cases.client_id'])
->with([
'person' => function ($qq) {
$qq->select(['person.id', 'person.full_name']);
},
'client' => function ($qq) {
$qq->select(['clients.id', 'clients.uuid', 'clients.person_id'])
->with([
'person' => function ($qqq) {
$qqq->select(['person.id', 'person.full_name']);
},
]);
},
]);
},
])
// force ordering by due_date DESC only
->orderByDesc('activities.due_date');
// Use a custom page parameter name to match the frontend DataTableServer
$activities = $query->paginate($perPage, ['*'], 'unread-page')->withQueryString();
// Build a distinct clients list for the filter (client UUID + client.person.full_name)
// Collect client_case_ids from both direct activities and via contracts
$baseForClients = Activity::query()
->select(['contract_id', 'client_case_id'])
->whereNotNull('due_date')
->whereDate('due_date', '<=', $today)
// Exclude activities that have been marked as read by this user
->whereNotExists(function ($q) use ($user, $today) {
$q->select(\DB::raw(1))
->from('activity_notification_reads')
->whereColumn('activity_notification_reads.activity_id', 'activities.id')
->where('activity_notification_reads.user_id', $user->id)
->whereDate('activity_notification_reads.due_date', '<=', $today)
->whereNotNull('activity_notification_reads.read_at');
})
->when($clientCaseIdsForFilter->isNotEmpty(), function ($q) use ($clientCaseIdsForFilter) {
$q->where(function ($qq) use ($clientCaseIdsForFilter) {
$qq->whereIn('activities.client_case_id', $clientCaseIdsForFilter)
->orWhereIn('activities.contract_id', Contract::query()->select('id')->whereIn('client_case_id', $clientCaseIdsForFilter));
});
})
->get();
$contractIds = $baseForClients->pluck('contract_id')->filter()->unique()->values();
$directCaseIds = $baseForClients->pluck('client_case_id')->filter()->unique()->values();
$mapContractToCase = $contractIds->isNotEmpty()
? Contract::query()->whereIn('id', $contractIds)->pluck('client_case_id', 'id')
: collect();
$caseIds = $directCaseIds
->merge($contractIds->map(fn ($cid) => $mapContractToCase->get($cid)))
->filter()
->unique()
->values();
// Map caseIds -> clientIds, then load clients and present as value(label)
$clientIds = $caseIds->isNotEmpty()
? ClientCase::query()->whereIn('id', $caseIds)->pluck('client_id')->filter()->unique()->values()
: collect();
$clients = $clientIds->isNotEmpty()
? Client::query()
->whereIn('id', $clientIds)
->with(['person:id,full_name'])
->get(['id', 'uuid', 'person_id'])
->map(fn ($c) => [
'value' => $c->uuid,
'label' => optional($c->person)->full_name ?: '(neznana stranka)',
])
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
->values()
: collect();
return Inertia::render('Notifications/Unread', [
'activities' => $activities,
'today' => $today,
'clients' => $clients,
]);
}
}
@@ -2,8 +2,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Http\Request;
class PaymentController extends Controller class PaymentController extends Controller
{ {
// //
+68 -54
View File
@@ -3,62 +3,56 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Person\Person; use App\Models\Person\Person;
use App\Models\BankAccount;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia;
class PersonController extends Controller class PersonController extends Controller
{ {
// //
public function show(Person $person){ public function show(Person $person) {}
} public function create(Request $request) {}
public function create(Request $request){ public function store(Request $request) {}
} public function update(Person $person, Request $request)
{
public function store(Request $request){
}
public function update(Person $person, Request $request){
$attributes = $request->validate([ $attributes = $request->validate([
'full_name' => 'string|max:255', 'full_name' => 'string|max:255',
'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 response()->json([ return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
'person' => [
'full_name' => $person->full_name,
'tax_number' => $person->tax_number,
'social_security_number' => $person->social_security_number,
'description' => $person->description
]
]);
} }
public function createAddress(Person $person, Request $request){ public function createAddress(Person $person, Request $request)
{
$attributes = $request->validate([ $attributes = $request->validate([
'address' => 'required|string|max:150', 'address' => 'required|string|max:150',
'country' => 'nullable|string', 'country' => 'nullable|string',
'post_code' => 'nullable|string|max:16',
'city' => 'nullable|string|max:100',
'type_id' => 'required|integer|exists:address_types,id', 'type_id' => 'required|integer|exists:address_types,id',
'description' => 'nullable|string|max:125' 'description' => 'nullable|string|max:125',
]); ]);
// Dedup: avoid duplicate address per person by (address, country) // Dedup: avoid duplicate address per person by (address, country)
$address = $person->addresses()->firstOrCreate([ $address = $person->addresses()->firstOrCreate([
'address' => $attributes['address'], 'address' => $attributes['address'],
'country' => $attributes['country'] ?? null, 'country' => $attributes['country'] ?? null,
'post_code' => $attributes['post_code'] ?? null,
'city' => $attributes['city'] ?? null,
], $attributes); ], $attributes);
return response()->json([ // Support Inertia form submissions (redirect back) and JSON (for API/axios)
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id) return back()->with('success', 'Address created')->with('flash_method', 'POST');
]);
} }
public function updateAddress(Person $person, int $address_id, Request $request) public function updateAddress(Person $person, int $address_id, Request $request)
@@ -66,24 +60,26 @@ public function updateAddress(Person $person, int $address_id, Request $request)
$attributes = $request->validate([ $attributes = $request->validate([
'address' => 'required|string|max:150', 'address' => 'required|string|max:150',
'country' => 'nullable|string', 'country' => 'nullable|string',
'post_code' => 'nullable|string|max:16',
'city' => 'nullable|string|max:100',
'type_id' => 'required|integer|exists:address_types,id', 'type_id' => 'required|integer|exists:address_types,id',
'description' => 'nullable|string|max:125' 'description' => 'nullable|string|max:125',
]); ]);
$address = $person->addresses()->with(['type'])->findOrFail($address_id); $address = $person->addresses()->with(['type'])->findOrFail($address_id);
$address->update($attributes); $address->update($attributes);
return response()->json([ return back()->with('success', 'Address updated')->with('flash_method', 'PUT');
'address' => $address
]);
} }
public function deleteAddress(Person $person, int $address_id, Request $request) 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 response()->json(['status' => 'ok']);
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
} }
public function createPhone(Person $person, Request $request) public function createPhone(Person $person, Request $request)
@@ -92,7 +88,9 @@ public function createPhone(Person $person, Request $request)
'nu' => 'required|string|max:50', 'nu' => 'required|string|max:50',
'country_code' => 'nullable|integer', 'country_code' => 'nullable|integer',
'type_id' => 'required|integer|exists:phone_types,id', 'type_id' => 'required|integer|exists:phone_types,id',
'description' => 'nullable|string|max:125' 'description' => 'nullable|string|max:125',
'validated' => 'sometimes|boolean',
'phone_type' => 'nullable|in:mobile,landline,voip',
]); ]);
// Dedup: avoid duplicate phone per person by (nu, country_code) // Dedup: avoid duplicate phone per person by (nu, country_code)
@@ -101,9 +99,7 @@ public function createPhone(Person $person, Request $request)
'country_code' => $attributes['country_code'] ?? null, 'country_code' => $attributes['country_code'] ?? null,
], $attributes); ], $attributes);
return response()->json([ return back()->with('success', 'Phone added successfully')->with('flash_method', 'POST');
'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id)
]);
} }
public function updatePhone(Person $person, int $phone_id, Request $request) public function updatePhone(Person $person, int $phone_id, Request $request)
@@ -112,23 +108,24 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
'nu' => 'required|string|max:50', 'nu' => 'required|string|max:50',
'country_code' => 'nullable|integer', 'country_code' => 'nullable|integer',
'type_id' => 'required|integer|exists:phone_types,id', 'type_id' => 'required|integer|exists:phone_types,id',
'description' => 'nullable|string|max:125' 'description' => 'nullable|string|max:125',
'validated' => 'sometimes|boolean',
'phone_type' => 'nullable|in:mobile,landline,voip',
]); ]);
$phone = $person->phones()->with(['type'])->findOrFail($phone_id); $phone = $person->phones()->with(['type'])->findOrFail($phone_id);
$phone->update($attributes); $phone->update($attributes);
return response()->json([ return back()->with('success', 'Phone updated successfully')->with('flash_method', 'PUT');
'phone' => $phone
]);
} }
public function deletePhone(Person $person, int $phone_id, Request $request) public function deletePhone(Person $person, int $phone_id, Request $request)
{ {
$phone = $person->phones()->findOrFail($phone_id); $phone = $person->phones()->findOrFail($phone_id);
$phone->delete(); // soft delete $phone->delete(); // soft delete
return response()->json(['status' => 'ok']);
return back()->with('success', 'Phone deleted')->with('flash_method', 'DELETE');
} }
public function createEmail(Person $person, Request $request) public function createEmail(Person $person, Request $request)
@@ -139,19 +136,25 @@ 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',
'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'],
], $attributes); ], $attributes);
return response()->json([ return back()->with('success', 'Email added successfully')->with('flash_method', 'POST');
'email' => \App\Models\Email::findOrFail($email->id)
]);
} }
public function updateEmail(Person $person, int $email_id, Request $request) public function updateEmail(Person $person, int $email_id, Request $request)
@@ -162,25 +165,36 @@ 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',
'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 response()->json([ return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
'email' => $email
]);
} }
public function deleteEmail(Person $person, int $email_id, Request $request) public function deleteEmail(Person $person, int $email_id, Request $request)
{ {
$email = $person->emails()->findOrFail($email_id); $email = $person->emails()->findOrFail($email_id);
$email->delete(); $email->delete();
return response()->json(['status' => 'ok']);
if ($request->header('X-Inertia')) {
return back()->with('success', 'Email deleted');
}
return back()->with('success', 'Email deleted')->with('flash_method', 'DELETE');
} }
// TRR (bank account) CRUD // TRR (bank account) CRUD
@@ -202,9 +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 response()->json([ return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
'trr' => BankAccount::findOrFail($trr->id)
]);
} }
public function updateTrr(Person $person, int $trr_id, Request $request) public function updateTrr(Person $person, int $trr_id, Request $request)
@@ -226,15 +239,16 @@ public function updateTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id); $trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->update($attributes); $trr->update($attributes);
return response()->json([ return back()->with('success', 'TRR updated successfully')->with('flash_method', 'PUT');
'trr' => $trr
]);
} }
public function deleteTrr(Person $person, int $trr_id, Request $request) 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 response()->json(['status' => 'ok']);
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
} }
} }
+203 -94
View File
@@ -3,139 +3,248 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\FieldJob; use App\Models\FieldJob;
use App\Services\ReferenceDataCache;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
class PhoneViewController extends Controller class PhoneViewController extends Controller
{ {
public function index(Request $request) public function __construct(protected ReferenceDataCache $referenceCache) {}
public function index(Request $request): \Inertia\Response
{ {
$userId = $request->user()->id; $userId = $request->user()->id;
$search = $request->input('search');
$clientFilter = $request->input('client');
$jobs = 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' => function ($pq) { if ($clientFilter) {
$pq->with(['addresses', 'phones']); $baseQuery->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
}]); $q->where('uuid', $clientFilter);
}, });
}
if ($search) {
$baseQuery->where(function ($q) use ($search) {
$q->whereHas('contract', function ($cq) use ($search) {
$cq->where('reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($pq) use ($search) {
$pq->where('full_name', 'ilike', '%'.$search.'%');
})
->orWhereHas('clientCase.client.person', function ($pq) use ($search) {
$pq->where('full_name', 'ilike', '%'.$search.'%');
});
});
});
}
$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');
$clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
$q->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at');
})
->with(['person:id,full_name'])
->get(['uuid', 'person_id'])
->map(fn ($c) => [
'uuid' => (string) $c->uuid,
'name' => (string) optional($c->person)->full_name,
]) ])
->orderByDesc('assigned_at') ->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
->limit(100) ->values();
->get();
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,
'view_mode' => 'assigned',
'filters' => [
'search' => $search,
'client' => $clientFilter,
],
]);
}
public function completedToday(Request $request): \Inertia\Response
{
$userId = $request->user()->id;
$search = $request->input('search');
$clientFilter = $request->input('client');
$start = now()->startOfDay();
$end = now()->endOfDay();
$query = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('cancelled_at')
->whereBetween('completed_at', [$start, $end])
->with([
'contract' => function ($q) {
$q->with([
'type:id,name',
'account',
'clientCase.person.address.type',
'clientCase.person.phones',
'clientCase.client:id,uuid,person_id',
'clientCase.client.person:id,full_name',
]);
},
])
->orderByDesc('completed_at');
// Apply client filter
if ($clientFilter) {
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
$q->where('uuid', $clientFilter);
});
}
// Apply search filter
if ($search) {
$query->where(function ($q) use ($search) {
$q->whereHas('contract', function ($cq) use ($search) {
$cq->where('reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($pq) use ($search) {
$pq->where('full_name', 'ilike', '%'.$search.'%');
})
->orWhereHas('clientCase.client.person', function ($pq) use ($search) {
$pq->where('full_name', 'ilike', '%'.$search.'%');
});
});
});
}
$clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
$q->where('assigned_user_id', $userId)
->whereNull('cancelled_at')
->whereBetween('completed_at', [$start, $end]);
})
->with(['person:id,full_name'])
->get(['uuid', 'person_id'])
->map(fn ($c) => [
'uuid' => (string) $c->uuid,
'name' => (string) optional($c->person)->full_name,
])
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
->values();
return Inertia::render('Phone/Index', [
'completedJobs' => Inertia::scroll(fn () => $query->paginate(15, pageName: 'completed')),
'clients' => $clients,
'view_mode' => 'completed-today',
'filters' => [
'search' => $search,
'client' => $clientFilter,
],
]); ]);
} }
public function showCase(\App\Models\ClientCase $clientCase, Request $request) public function showCase(\App\Models\ClientCase $clientCase, Request $request)
{ {
$userId = $request->user()->id; $userId = $request->user()->id;
$completedMode = $request->boolean('completed');
// Eager load client case with person details // Eager load case with person details
$case = \App\Models\ClientCase::query() $case = $clientCase->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts');
->with(['person' => fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts'])])
->findOrFail($clientCase->id);
// Determine contracts of this case assigned to the current user via FieldJobs and still active // Query contracts based on field jobs
$assignedContractIds = FieldJob::query() $contractsQuery = FieldJob::query()
->where('assigned_user_id', $userId) ->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id)) ->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->pluck('contract_id') ->when($completedMode,
->unique() fn ($q) => $q->whereNull('cancelled_at')->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]),
->values(); fn ($q) => $q->whereNull('completed_at')->whereNull('cancelled_at')
);
// Get contracts with relationships
$contracts = \App\Models\Contract::query() $contracts = \App\Models\Contract::query()
->where('client_case_id', $case->id) ->where('client_case_id', $case->id)
->whereIn('id', $assignedContractIds) ->whereIn('id', $contractsQuery->pluck('contract_id')->unique())
->with(['type:id,name', 'account']) ->with(['type:id,name', 'account', 'latestObject'])
->orderByDesc('created_at') ->orderByDesc('created_at')
->get(); ->get();
// Attach latest object (if any) to each contract as last_object for display // Build merged documents
if ($contracts->isNotEmpty()) { $documents = $case->documents()
$byId = $contracts->keyBy('id');
$latestObjects = \App\Models\CaseObject::query()
->whereIn('contract_id', $byId->keys())
->whereNull('deleted_at')
->select('id', 'reference', 'name', 'description', 'type', 'contract_id', 'created_at')
->orderByDesc('created_at')
->get()
->groupBy('contract_id')
->map(function ($group) {
return $group->first();
});
foreach ($latestObjects as $cid => $obj) {
if (isset($byId[$cid])) {
$byId[$cid]->setAttribute('last_object', $obj);
}
}
}
// Build merged documents: case documents + documents of assigned contracts
$contractRefMap = [];
foreach ($contracts as $c) {
$contractRefMap[$c->id] = $c->reference;
}
$contractDocs = \App\Models\Document::query()
->where('documentable_type', \App\Models\Contract::class)
->whereIn('documentable_id', $assignedContractIds)
->orderByDesc('created_at') ->orderByDesc('created_at')
->get() ->get()
->map(function ($d) use ($contractRefMap) { ->map(fn ($d) => array_merge($d->toArray(), [
$arr = $d->toArray(); 'documentable_type' => \App\Models\ClientCase::class,
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null; 'client_case_uuid' => $case->uuid,
$arr['documentable_type'] = \App\Models\Contract::class; ]))
$arr['contract_uuid'] = optional(\App\Models\Contract::withTrashed()->find($d->documentable_id))->uuid; ->concat(
\App\Models\Document::query()
->where('documentable_type', \App\Models\Contract::class)
->whereIn('documentable_id', $contracts->pluck('id'))
->with('documentable:id,uuid,reference')
->orderByDesc('created_at')
->get()
->map(fn ($d) => array_merge($d->toArray(), [
'contract_reference' => $d->documentable?->reference,
'contract_uuid' => $d->documentable?->uuid,
]))
)
->sortByDesc('created_at')
->values();
return $arr; // Get segment IDs for filtering actions
}); $segmentIds = \App\Models\FieldJobSetting::query()
->whereIn('id', $contractsQuery->pluck('field_job_setting_id')->filter()->unique())
$caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) { ->pluck('segment_id')
$arr = $d->toArray(); ->filter()
$arr['documentable_type'] = \App\Models\ClientCase::class; ->unique();
$arr['client_case_uuid'] = $case->uuid;
return $arr;
});
$documents = $caseDocs->concat($contractDocs)->sortByDesc('created_at')->values();
// Provide minimal types for PersonInfoGrid
$types = [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
];
// Case activities (compact for phone): latest 20 with relations
$activities = $case->activities()
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
->orderByDesc('created_at')
->limit(20)
->get()
->map(function ($a) {
$a->setAttribute('user_name', optional($a->user)->name);
return $a;
});
return Inertia::render('Phone/Case/Index', [ return Inertia::render('Phone/Case/Index', [
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(), 'client' => $case->client->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts'),
'client_case' => $case, 'client_case' => $case,
'contracts' => $contracts, 'contracts' => $contracts,
'documents' => $documents, 'documents' => $documents,
'types' => $types, 'types' => [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
],
'account_types' => \App\Models\AccountType::all(), 'account_types' => \App\Models\AccountType::all(),
'actions' => \App\Models\Action::with('decisions')->get(), 'actions' => \App\Models\Action::query()
'activities' => $activities, ->when($segmentIds->isNotEmpty(), fn ($q) => $q->whereIn('segment_id', $segmentIds))
->with([
'decisions:id,name,color_tag,auto_mail,email_template_id',
'decisions.emailTemplate:id,name,entity_types,allow_attachments',
])
->get(['id', 'name', 'color_tag', 'segment_id']),
'activities' => $case->activities()
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
->orderByDesc('created_at')
->limit(20)
->get()
->map(fn ($a) => $a->setAttribute('user_name', $a->user?->name)),
'completed_mode' => $completedMode,
]); ]);
} }
} }
+1 -1
View File
@@ -2,9 +2,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Post;
use App\Http\Requests\StorePostRequest; use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest; use App\Http\Requests\UpdatePostRequest;
use App\Models\Post;
class PostController extends Controller class PostController extends Controller
{ {
+460
View File
@@ -0,0 +1,460 @@
<?php
namespace App\Http\Controllers;
use App\Models\Report;
use App\Services\ReportQueryBuilder;
use Illuminate\Http\Request;
use Inertia\Inertia;
// facades referenced with fully-qualified names below to satisfy static analysis
class ReportController extends Controller
{
public function __construct(protected ReportQueryBuilder $queryBuilder) {}
public function index(Request $request)
{
$reports = Report::where('enabled', true)
->orderBy('order')
->orderBy('name')
->get()
->map(fn ($r) => [
'slug' => $r->slug,
'name' => $r->name,
'description' => $r->description,
'category' => $r->category,
])
->values();
return Inertia::render('Reports/Index', [
'reports' => $reports,
]);
}
public function show(string $slug, Request $request)
{
$report = Report::with(['filters', 'columns'])
->where('slug', $slug)
->where('enabled', true)
->firstOrFail();
// Accept filters & pagination from query and return initial data for server-driven table
$inputs = $this->buildInputsArray($report);
$filters = $this->validateFilters($inputs, $request);
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
$perPage = (int) ($request->integer('per_page') ?: 25);
$query = $this->queryBuilder->build($report, $filters);
$paginator = $query->paginate($perPage);
$rows = collect($paginator->items())
->map(fn ($row) => $this->normalizeRow($row))
->values();
return Inertia::render('Reports/Show', [
'slug' => $report->slug,
'name' => $report->name,
'description' => $report->description,
'inputs' => $inputs,
'columns' => $this->buildColumnsArray($report),
'rows' => $rows,
'meta' => [
'total' => $paginator->total(),
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'last_page' => $paginator->lastPage(),
],
'query' => array_filter($filters, fn ($v) => $v !== null && $v !== ''),
]);
}
public function data(string $slug, Request $request)
{
$report = Report::with(['filters', 'columns'])
->where('slug', $slug)
->where('enabled', true)
->firstOrFail();
$inputs = $this->buildInputsArray($report);
$filters = $this->validateFilters($inputs, $request);
$perPage = (int) ($request->integer('per_page') ?: 25);
$query = $this->queryBuilder->build($report, $filters);
$paginator = $query->paginate($perPage);
$rows = collect($paginator->items())
->map(fn ($row) => $this->normalizeRow($row))
->values();
return response()->json([
'data' => $rows,
'total' => $paginator->total(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
]);
}
public function export(string $slug, Request $request)
{
$report = Report::with(['filters', 'columns'])
->where('slug', $slug)
->where('enabled', true)
->firstOrFail();
$inputs = $this->buildInputsArray($report);
$filters = $this->validateFilters($inputs, $request);
$format = strtolower((string) $request->get('format', 'csv'));
$query = $this->queryBuilder->build($report, $filters);
$rows = $query->get()->map(fn ($row) => $this->normalizeRow($row));
$columns = $this->buildColumnsArray($report);
$filename = $report->slug.'-'.now()->format('Ymd_His');
if ($format === 'pdf') {
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [
'name' => $report->name,
'columns' => $columns,
'rows' => $rows,
]);
return $pdf->download($filename.'.pdf');
}
if ($format === 'xlsx') {
$keys = array_map(fn ($c) => $c['key'], $columns);
$headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns);
// Convert values for correct Excel rendering (dates, numbers, text)
$array = $this->prepareXlsxArray($rows, $keys);
// Build base column formats: text for contracts, EU datetime for *_at; numbers are formatted per-cell in AfterSheet
$columnFormats = [];
$textColumns = [];
$dateColumns = [];
foreach ($keys as $i => $key) {
$letter = $this->excelColumnLetter($i + 1);
if ($key === 'contract_reference') {
$columnFormats[$letter] = '@';
$textColumns[] = $letter;
continue;
}
if (str_ends_with($key, '_at')) {
$columnFormats[$letter] = 'dd.mm.yyyy hh:mm';
$dateColumns[] = $letter;
continue;
}
}
// Anonymous export with custom value binder to force text where needed
$export = new class($array, $headings, $columnFormats, $textColumns, $dateColumns) extends \Maatwebsite\Excel\DefaultValueBinder implements \Maatwebsite\Excel\Concerns\FromArray, \Maatwebsite\Excel\Concerns\ShouldAutoSize, \Maatwebsite\Excel\Concerns\WithColumnFormatting, \Maatwebsite\Excel\Concerns\WithCustomValueBinder, \Maatwebsite\Excel\Concerns\WithEvents, \Maatwebsite\Excel\Concerns\WithHeadings
{
public function __construct(private array $array, private array $headings, private array $formats, private array $textColumns, private array $dateColumns) {}
public function array(): array
{
return $this->array;
}
public function headings(): array
{
return $this->headings;
}
public function columnFormats(): array
{
return $this->formats;
}
public function bindValue(\PhpOffice\PhpSpreadsheet\Cell\Cell $cell, $value): bool
{
$col = preg_replace('/\d+/', '', $cell->getCoordinate()); // e.g., B from B2
// Force text for configured columns or very long digit-only strings (>15)
if (in_array($col, $this->textColumns, true) || (is_string($value) && ctype_digit($value) && strlen($value) > 15)) {
$cell->setValueExplicit((string) $value, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);
return true;
}
return parent::bindValue($cell, $value);
}
public function registerEvents(): array
{
return [
\Maatwebsite\Excel\Events\AfterSheet::class => function (\Maatwebsite\Excel\Events\AfterSheet $event) {
$sheet = $event->sheet->getDelegate();
// Data starts at row 2 (row 1 is headings)
$rowIndex = 2;
foreach ($this->array as $row) {
foreach (array_values($row) as $i => $val) {
$colLetter = $this->colLetter($i + 1);
if (in_array($colLetter, $this->textColumns, true) || in_array($colLetter, $this->dateColumns, true)) {
continue; // already handled via columnFormats or binder
}
$coord = $colLetter.$rowIndex;
$fmt = null;
if (is_int($val)) {
// Integer: thousands separator, no decimals
$fmt = '#,##0';
} elseif (is_float($val)) {
// Float: show decimals only if fractional part exists
$fmt = (floor($val) != $val) ? '#,##0.00' : '#,##0';
}
if ($fmt) {
$sheet->getStyle($coord)->getNumberFormat()->setFormatCode($fmt);
}
}
$rowIndex++;
}
},
];
}
private function colLetter(int $index): string
{
$letter = '';
while ($index > 0) {
$mod = ($index - 1) % 26;
$letter = chr(65 + $mod).$letter;
$index = intdiv($index - $mod, 26) - 1;
}
return $letter;
}
};
return \Maatwebsite\Excel\Facades\Excel::download($export, $filename.'.xlsx');
}
// Default CSV export
$keys = array_map(fn ($c) => $c['key'], $columns);
$headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns);
$csv = fopen('php://temp', 'r+');
fputcsv($csv, $headings);
foreach ($rows as $r) {
$line = collect($keys)->map(fn ($k) => data_get($r, $k))->toArray();
fputcsv($csv, $line);
}
rewind($csv);
$content = stream_get_contents($csv) ?: '';
fclose($csv);
return response($content, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="'.$filename.'.csv"',
]);
}
/**
* Lightweight users lookup for filters: id + name, optional search and limit.
*/
public function users(Request $request)
{
$search = trim((string) $request->get('search', ''));
$limit = (int) ($request->integer('limit') ?: 10);
$q = \App\Models\User::query()->orderBy('name');
if ($search !== '') {
$like = '%'.mb_strtolower($search).'%';
$q->where(function ($qq) use ($like) {
$qq->whereRaw('LOWER(name) LIKE ?', [$like])
->orWhereRaw('LOWER(email) LIKE ?', [$like]);
});
}
$users = $q->limit(max(1, min(50, $limit)))->get(['id', 'name']);
return response()->json($users);
}
/**
* Lightweight clients lookup for filters: uuid + name (person full_name), optional search and limit.
*/
public function clients(Request $request)
{
$clients = \App\Models\Client::query()
->with('person:id,full_name')
->get()
->map(fn ($c) => [
'id' => $c->uuid,
'name' => $c->person->full_name ?? 'Unknown',
])
->sortBy('name')
->values();
return response()->json($clients);
}
/**
* Lightweight actions lookup for select:action filters.
*/
public function actions(Request $request)
{
$actions = \App\Models\Action::query()
->orderBy('name')
->get(['id', 'name'])
->map(fn ($a) => ['id' => $a->id, 'name' => $a->name])
->values();
return response()->json($actions);
}
/**
* Lightweight decisions lookup for select:decision filters.
* Optionally filtered by action_id (for dependent filter UI).
*/
public function decisions(Request $request)
{
$actionId = $request->integer('action_id', 0) ?: null;
$q = \App\Models\Decision::query()->orderBy('name');
if ($actionId !== null) {
$q->whereHas('actions', fn ($qq) => $qq->where('actions.id', $actionId));
}
$decisions = $q->get(['id', 'name'])
->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])
->values();
return response()->json($decisions);
}
/**
* Build validation rules based on inputs descriptor and validate.
*
* @param array<int, array<string, mixed>> $inputs
* @return array<string, mixed>
*/
protected function validateFilters(array $inputs, Request $request): array
{
$rules = [];
foreach ($inputs as $inp) {
$key = $inp['key'];
$type = $inp['type'] ?? 'string';
$nullable = ($inp['nullable'] ?? true) ? 'nullable' : 'required';
$rules[$key] = match ($type) {
'date' => [$nullable, 'date'],
'integer' => [$nullable, 'integer'],
'select:user' => [$nullable, 'integer', 'exists:users,id'],
'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
'select:action' => [$nullable, 'integer', 'exists:actions,id'],
'select:decision' => [$nullable, 'integer', 'exists:decisions,id'],
default => [$nullable, 'string'],
};
}
return $request->validate($rules);
}
/**
* Build inputs array from report filters.
*/
protected function buildInputsArray(Report $report): array
{
return $report->filters->map(fn ($filter) => [
'key' => $filter->key,
'type' => $filter->type,
'label' => $filter->label,
'nullable' => $filter->nullable,
'default' => $filter->default_value,
'options' => $filter->options,
])->toArray();
}
/**
* Build columns array from report columns.
*/
protected function buildColumnsArray(Report $report): array
{
return $report->columns
->where('visible', true)
->map(fn ($col) => [
'key' => $col->key,
'label' => $col->label,
])
->toArray();
}
/**
* Ensure derived export/display fields exist on row objects.
*/
protected function normalizeRow(object $row): object
{
if (isset($row->contract) && ! isset($row->contract_reference)) {
$row->contract_reference = $row->contract->reference ?? null;
}
if (isset($row->assignedUser) && ! isset($row->assigned_user_name)) {
$row->assigned_user_name = $row->assignedUser->name ?? null;
}
return $row;
}
/**
* Convert rows for XLSX export: dates to Excel serial numbers, numbers to numeric, contract refs to text.
*
* @param iterable<int, object|array> $rows
* @param array<int, string> $keys
* @return array<int, array<int, mixed>>
*/
protected function prepareXlsxArray(iterable $rows, array $keys): array
{
$out = [];
foreach ($rows as $r) {
$line = [];
foreach ($keys as $k) {
$v = data_get($r, $k);
if ($k === 'contract_reference') {
$line[] = (string) $v;
continue;
}
if (str_ends_with($k, '_at')) {
if (empty($v)) {
$line[] = null;
} else {
try {
$dt = \Carbon\Carbon::parse($v);
$line[] = \PhpOffice\PhpSpreadsheet\Shared\Date::dateTimeToExcel($dt);
} catch (\Throwable $e) {
$line[] = (string) $v;
}
}
continue;
}
if (is_int($v) || is_float($v)) {
$line[] = $v;
} elseif (is_numeric($v) && is_string($v)) {
// cast numeric-like strings unless they are identifiers that we want as text
$line[] = (strpos($k, 'id') !== false) ? (int) $v : ($v + 0);
} else {
$line[] = $v;
}
}
$out[] = $line;
}
return $out;
}
/**
* Convert 1-based index to Excel column letter.
*/
protected function excelColumnLetter(int $index): string
{
$letter = '';
while ($index > 0) {
$mod = ($index - 1) % 26;
$letter = chr(65 + $mod).$letter;
$index = intdiv($index - $mod, 26) - 1;
}
return $letter;
}
}
+158 -39
View File
@@ -2,12 +2,20 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Exports\SegmentContractsExport;
use App\Http\Requests\ExportSegmentContractsRequest;
use App\Http\Requests\StoreSegmentRequest; use App\Http\Requests\StoreSegmentRequest;
use App\Http\Requests\UpdateSegmentRequest; use App\Http\Requests\UpdateSegmentRequest;
use App\Models\Client;
use App\Models\Contract;
use App\Models\Segment; use App\Models\Segment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
class SegmentController extends Controller class SegmentController extends Controller
{ {
@@ -44,56 +52,111 @@ public function index()
]); ]);
} }
public function show(\App\Models\Segment $segment) public function show(Segment $segment)
{ {
// Retrieve contracts that are active in this segment, eager-loading required relations
$search = request('search'); $search = request('search');
$contractsQuery = \App\Models\Contract::query() $clientFilter = request('client') ?? request('client_id');
->whereHas('segments', function ($q) use ($segment) { $perPage = request()->integer('perPage', request()->integer('per_page', 15));
$perPage = max(1, min(200, $perPage));
$contracts = $this->buildContractsQuery($segment, $search, $clientFilter)
->paginate($perPage)
->withQueryString();
$contracts = $this->hydrateClientShortcut($contracts);
// Hide addresses array since we're using the singular address relationship
$contracts->getCollection()->each(function ($contract) {
$contract->clientCase?->person?->makeHidden('addresses');
$contract->clientCase?->client?->person?->makeHidden('addresses');
});
$clients = Client::query()
->whereHas('clientCases.contracts.segments', function ($q) use ($segment) {
$q->where('segments.id', $segment->id) $q->where('segments.id', $segment->id)
->where('contract_segment.active', '=', 1); ->where('contract_segment.active', '=', 1);
}) })
->with([ ->with(['person:id,full_name'])
'clientCase.person', ->get(['uuid', 'person_id'])
'clientCase.client.person', ->map(function ($c) {
'type', return [
'account', 'uuid' => (string) $c->uuid,
]) 'name' => (string) optional($c->person)->full_name,
->latest('id'); ];
})
if (!empty($search)) { ->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
$contractsQuery->where(function ($qq) use ($search) { ->values();
$qq->where('contracts.reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
})
->orWhereHas('clientCase.client.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
});
});
}
$contracts = $contractsQuery
->paginate(15)
->withQueryString();
// Mirror client onto the contract to simplify frontend access (c.client.person.full_name)
$items = collect($contracts->items());
$items->each(function ($contract) {
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
$contract->setRelation('client', $contract->clientCase->client);
}
});
if (method_exists($contracts, 'setCollection')) {
$contracts->setCollection($items);
}
return Inertia::render('Segments/Show', [ return Inertia::render('Segments/Show', [
'segment' => $segment->only(['id','name','description']), 'segment' => $segment->only(['id', 'name', 'description']),
'contracts' => $contracts, 'contracts' => $contracts,
'clients' => $clients,
]); ]);
} }
public function export(ExportSegmentContractsRequest $request, Segment $segment)
{
$data = $request->validated();
$client = $this->resolveClient($data['client'] ?? null);
$columns = array_values(array_unique($data['columns']));
$query = $this->buildContractsQuery(
$segment,
$data['search'] ?? null,
$data['client'] ?? null
);
if (($data['scope'] ?? ExportSegmentContractsRequest::SCOPE_ALL) === ExportSegmentContractsRequest::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($segment, $client);
return Excel::download(new SegmentContractsExport($query, $columns), $filename);
}
private function resolveClient(?string $identifier): ?Client
{
if (empty($identifier)) {
return null;
}
$query = Client::query()->with(['person:id,full_name']);
if (Str::isUuid($identifier)) {
$query->where('uuid', $identifier);
} elseif (is_numeric($identifier)) {
$query->where('id', (int) $identifier);
} else {
$query->where('uuid', $identifier);
}
return $query->first();
}
private function buildExportFilename(Segment $segment, ?Client $client): string
{
$datePrefix = now()->format('dmy');
$segmentName = $this->slugify($segment->name ?? 'segment');
$base = sprintf('%s_%s-Pogodbe', $datePrefix, $segmentName);
if ($client && $client->person?->full_name) {
$clientName = $this->slugify($client->person->full_name);
return sprintf('%s_%s.xlsx', $base, $clientName);
}
return sprintf('%s.xlsx', $base);
}
private function slugify(string $value): string
{
$slug = trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $value), '-');
return $slug !== '' ? $slug : 'data';
}
public function settings(Request $request) public function settings(Request $request)
{ {
return Inertia::render('Settings/Segments/Index', [ return Inertia::render('Settings/Segments/Index', [
@@ -120,8 +183,64 @@ public function update(UpdateSegmentRequest $request, Segment $segment)
'name' => $data['name'], 'name' => $data['name'],
'description' => $data['description'] ?? null, 'description' => $data['description'] ?? null,
'active' => $data['active'] ?? $segment->active, 'active' => $data['active'] ?? $segment->active,
'exclude' => $data['exclude'] ?? $segment->exclude,
]); ]);
return to_route('settings.segments')->with('success', 'Segment updated'); return to_route('settings.segments')->with('success', 'Segment updated');
} }
private function buildContractsQuery(Segment $segment, ?string $search, ?string $clientFilter): Builder
{
$query = Contract::query()
->whereHas('segments', function ($q) use ($segment) {
$q->where('segments.id', $segment->id)
->where('contract_segment.active', '=', 1);
})
->with([
'clientCase.person.address',
'type',
'account',
])
->latest('id');
if (! empty($clientFilter)) {
$query->whereHas('clientCase.client', function ($q) use ($clientFilter) {
if (is_numeric($clientFilter)) {
$q->where('clients.id', (int) $clientFilter);
} else {
$q->where('clients.uuid', $clientFilter);
}
});
}
if (! empty($search)) {
$query->where(function ($qq) use ($search) {
$qq->where('contracts.reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
})
->orWhereHas('clientCase.client.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
});
});
}
return $query;
}
private function hydrateClientShortcut(LengthAwarePaginator $contracts): LengthAwarePaginator
{
$items = collect($contracts->items());
$items->each(function (Contract $contract) {
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
$contract->setRelation('client', $contract->clientCase->client);
}
});
if (method_exists($contracts, 'setCollection')) {
$contracts->setCollection($items);
}
return $contracts;
}
} }
+2 -1
View File
@@ -9,7 +9,8 @@ class SettingController extends Controller
{ {
// //
public function index(Request $request){ public function index(Request $request)
{
return Inertia::render('Settings/Index'); return Inertia::render('Settings/Index');
} }
@@ -0,0 +1,293 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Report;
use App\Models\ReportEntity;
use App\Models\ReportColumn;
use App\Models\ReportFilter;
use App\Models\ReportCondition;
use App\Models\ReportOrder;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ReportSettingsController extends Controller
{
public function index()
{
$reports = Report::orderBy('order')->orderBy('name')->get();
return Inertia::render('Settings/Reports/Index', [
'reports' => $reports,
]);
}
public function edit(Report $report)
{
$report->load(['entities', 'columns', 'filters', 'conditions', 'orders']);
return Inertia::render('Settings/Reports/Edit', [
'report' => $report,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'slug' => 'required|string|unique:reports,slug|max:255',
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'category' => 'nullable|string|max:100',
'enabled' => 'boolean',
'order' => 'integer',
]);
$report = Report::create($validated);
return redirect()->route('settings.reports.index')
->with('success', 'Report created successfully.');
}
public function update(Request $request, Report $report)
{
$validated = $request->validate([
'slug' => 'required|string|unique:reports,slug,' . $report->id . '|max:255',
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'category' => 'nullable|string|max:100',
'enabled' => 'boolean',
'order' => 'integer',
]);
$report->update($validated);
return redirect()->route('settings.reports.index')
->with('success', 'Report updated successfully.');
}
public function destroy(Report $report)
{
$report->delete();
return redirect()->route('settings.reports.index')
->with('success', 'Report deleted successfully.');
}
public function toggleEnabled(Report $report)
{
$report->update(['enabled' => !$report->enabled]);
return back()->with('success', 'Report status updated.');
}
// Entity CRUD
public function storeEntity(Request $request, Report $report)
{
$validated = $request->validate([
'model_class' => 'required|string|max:255',
'alias' => 'nullable|string|max:50',
'join_type' => 'required|in:base,join,leftJoin,rightJoin',
'join_first' => 'nullable|string|max:100',
'join_operator' => 'nullable|string|max:10',
'join_second' => 'nullable|string|max:100',
'order' => 'integer',
]);
$report->entities()->create($validated);
return back()->with('success', 'Entity added successfully.');
}
public function updateEntity(Request $request, ReportEntity $entity)
{
$validated = $request->validate([
'model_class' => 'required|string|max:255',
'alias' => 'nullable|string|max:50',
'join_type' => 'required|in:base,join,leftJoin,rightJoin',
'join_first' => 'nullable|string|max:100',
'join_operator' => 'nullable|string|max:10',
'join_second' => 'nullable|string|max:100',
'order' => 'integer',
]);
$entity->update($validated);
return back()->with('success', 'Entity updated successfully.');
}
public function destroyEntity(ReportEntity $entity)
{
$entity->delete();
return back()->with('success', 'Entity deleted successfully.');
}
// Column CRUD
public function storeColumn(Request $request, Report $report)
{
$validated = $request->validate([
'key' => 'required|string|max:100',
'label' => 'required|string|max:255',
'type' => 'required|string|max:50',
'expression' => 'required|string',
'sortable' => 'boolean',
'visible' => 'boolean',
'order' => 'integer',
'format_options' => 'nullable|array',
]);
$report->columns()->create($validated);
return back()->with('success', 'Column added successfully.');
}
public function updateColumn(Request $request, ReportColumn $column)
{
$validated = $request->validate([
'key' => 'required|string|max:100',
'label' => 'required|string|max:255',
'type' => 'required|string|max:50',
'expression' => 'required|string',
'sortable' => 'boolean',
'visible' => 'boolean',
'order' => 'integer',
'format_options' => 'nullable|array',
]);
$column->update($validated);
return back()->with('success', 'Column updated successfully.');
}
public function destroyColumn(ReportColumn $column)
{
$column->delete();
return back()->with('success', 'Column deleted successfully.');
}
// Filter CRUD
public function storeFilter(Request $request, Report $report)
{
$validated = $request->validate([
'key' => 'required|string|max:100',
'label' => 'required|string|max:255',
'type' => 'required|string|max:50',
'nullable' => 'boolean',
'default_value' => 'nullable|string',
'options' => 'nullable|array',
'data_source' => 'nullable|string|max:255',
'order' => 'integer',
]);
$report->filters()->create($validated);
return back()->with('success', 'Filter added successfully.');
}
public function updateFilter(Request $request, ReportFilter $filter)
{
$validated = $request->validate([
'key' => 'required|string|max:100',
'label' => 'required|string|max:255',
'type' => 'required|string|max:50',
'nullable' => 'boolean',
'default_value' => 'nullable|string',
'options' => 'nullable|array',
'data_source' => 'nullable|string|max:255',
'order' => 'integer',
]);
$filter->update($validated);
return back()->with('success', 'Filter updated successfully.');
}
public function destroyFilter(ReportFilter $filter)
{
$filter->delete();
return back()->with('success', 'Filter deleted successfully.');
}
// Condition CRUD
public function storeCondition(Request $request, Report $report)
{
$validated = $request->validate([
'column' => 'required|string|max:255',
'operator' => 'required|string|max:50',
'value_type' => 'required|in:static,filter,expression',
'value' => 'nullable|string',
'filter_key' => 'nullable|string|max:100',
'logical_operator' => 'required|in:AND,OR',
'group_id' => 'nullable|integer',
'order' => 'integer',
'enabled' => 'boolean',
]);
$report->conditions()->create($validated);
return back()->with('success', 'Condition added successfully.');
}
public function updateCondition(Request $request, ReportCondition $condition)
{
$validated = $request->validate([
'column' => 'required|string|max:255',
'operator' => 'required|string|max:50',
'value_type' => 'required|in:static,filter,expression',
'value' => 'nullable|string',
'filter_key' => 'nullable|string|max:100',
'logical_operator' => 'required|in:AND,OR',
'group_id' => 'nullable|integer',
'order' => 'integer',
'enabled' => 'boolean',
]);
$condition->update($validated);
return back()->with('success', 'Condition updated successfully.');
}
public function destroyCondition(ReportCondition $condition)
{
$condition->delete();
return back()->with('success', 'Condition deleted successfully.');
}
// Order CRUD
public function storeOrder(Request $request, Report $report)
{
$validated = $request->validate([
'column' => 'required|string|max:255',
'direction' => 'required|in:ASC,DESC',
'order' => 'integer',
]);
$report->orders()->create($validated);
return back()->with('success', 'Order clause added successfully.');
}
public function updateOrder(Request $request, ReportOrder $order)
{
$validated = $request->validate([
'column' => 'required|string|max:255',
'direction' => 'required|in:ASC,DESC',
'order' => 'integer',
]);
$order->update($validated);
return back()->with('success', 'Order clause updated successfully.');
}
public function destroyOrder(ReportOrder $order)
{
$order->delete();
return back()->with('success', 'Order clause deleted successfully.');
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Models\SmsLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class SmsWebhookController extends Controller
{
/**
* Handle smsapi.si delivery reports (GET) and replies (POST).
* This endpoint accepts both methods as the provider may use either.
*/
public function __invoke(Request $request)
{
// Delivery report via GET: id (int) and status (string)
if ($request->query->has('id')) {
$providerId = (string) ((int) $request->query('id'));
$status = trim(strip_tags((string) $request->query('status', '')));
$log = SmsLog::query()->where('provider_message_id', $providerId)->first();
if ($log) {
$meta = (array) $log->meta;
$meta['delivery_report'] = [
'raw_status' => $status,
'received_at' => now()->toIso8601String(),
];
// Naive mapping: mark delivered for common success statuses
$normalized = strtoupper($status);
if (in_array($normalized, ['DELIVERED', 'DELIVRD', 'OK'], true)) {
$log->status = 'delivered';
$log->delivered_at = now();
} elseif (in_array($normalized, ['FAILED', 'UNDELIV', 'UNDELIVERED', 'ERROR'], true)) {
$log->status = 'failed';
$log->failed_at = now();
$log->error_code = $normalized;
}
$log->meta = $meta;
$log->save();
} else {
Log::warning('sms.webhook.delivery.unknown_id', ['provider_id' => $providerId, 'status' => $status]);
}
return response()->json(['ok' => true]);
}
// Reply via POST: smsId, m (message), from, to, time
if ($request->isMethod('post') && $request->post('smsId')) {
$providerId = (string) ((int) $request->post('smsId'));
$msg = trim(strip_tags((string) $request->post('m', '')));
$fromNumber = (string) $request->post('from', '');
$toNumber = (string) $request->post('to', '');
$timestamp = (int) $request->post('time', time());
$log = SmsLog::query()->where('provider_message_id', $providerId)->first();
if ($log) {
$meta = (array) $log->meta;
$replies = isset($meta['replies']) && is_array($meta['replies']) ? $meta['replies'] : [];
$replies[] = [
'message' => $msg,
'from' => $fromNumber,
'to' => $toNumber,
'time' => date('c', $timestamp),
];
$meta['replies'] = $replies;
$log->meta = $meta;
$log->save();
} else {
Log::warning('sms.webhook.reply.unknown_id', [
'provider_id' => $providerId,
'from' => $fromNumber,
'to' => $toNumber,
]);
}
return response()->json(['ok' => true]);
}
// Unknown payload
return response()->json(['ok' => false, 'reason' => 'unsupported payload'], 400);
}
}
+172 -9
View File
@@ -3,10 +3,13 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Action; use App\Models\Action;
use App\Models\ArchiveSetting;
use App\Models\Decision; use App\Models\Decision;
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\Support\Facades\DB; use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
class WorkflowController extends Controller class WorkflowController extends Controller
@@ -14,9 +17,14 @@ class WorkflowController extends Controller
public function index(Request $request) public function index(Request $request)
{ {
return Inertia::render('Settings/Workflow/Index', [ return Inertia::render('Settings/Workflow/Index', [
'actions' => Action::query()->with(['decisions', 'segment'])->withCount('activities')->get(), 'actions' => Action::query()->with(['decisions', 'segment'])->withCount('activities')->orderBy('id')->get(),
'decisions' => Decision::query()->with('actions')->withCount('activities')->get(), 'decisions' => Decision::query()->with(['actions', 'events'])->withCount('activities')->orderBy('id')->get(),
'segments' => Segment::query()->get(), 'segments' => Segment::query()->get(),
'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']),
'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']),
'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']),
'condition_fields' => ConditionEvaluator::availableFields(),
'condition_operators' => ConditionEvaluator::availableOperators(),
]); ]);
} }
@@ -41,7 +49,7 @@ public function storeAction(Request $request)
'segment_id' => $attributes['segment_id'] ?? null, 'segment_id' => $attributes['segment_id'] ?? null,
]); ]);
if (!empty($decisionIds)) { if (! empty($decisionIds)) {
$row->decisions()->sync($decisionIds); $row->decisions()->sync($decisionIds);
} }
}); });
@@ -59,12 +67,12 @@ public function updateAction(int $id, Request $request)
'segment_id' => 'nullable|integer|exists:segments,id', 'segment_id' => 'nullable|integer|exists:segments,id',
'decisions' => 'nullable|array', 'decisions' => 'nullable|array',
'decisions.*.id' => 'required_with:decisions.*|integer|exists:decisions,id', 'decisions.*.id' => 'required_with:decisions.*|integer|exists:decisions,id',
'decisions.*.name' => 'required_with:decisions.*|string|max:50' 'decisions.*.name' => 'required_with:decisions.*|string|max:50',
]); ]);
$decisionIds = collect($attributes['decisions'] ?? [])->pluck('id')->toArray(); $decisionIds = collect($attributes['decisions'] ?? [])->pluck('id')->toArray();
\DB::transaction(function() use ($attributes, $decisionIds, $row) { \DB::transaction(function () use ($attributes, $decisionIds, $row) {
$row->update([ $row->update([
'name' => $attributes['name'], 'name' => $attributes['name'],
'color_tag' => $attributes['color_tag'], 'color_tag' => $attributes['color_tag'],
@@ -78,26 +86,99 @@ 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',
'auto_mail' => 'sometimes|boolean',
'email_template_id' => 'nullable|integer|exists:email_templates,id',
'actions' => 'nullable|array', 'actions' => 'nullable|array',
'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id', 'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id',
'actions.*.name' => 'required_with:actions.*|string|max:50', 'actions.*.name' => 'required_with:actions.*|string|max:50',
'events' => 'nullable|array',
'events.*.id' => 'required_with:events.*|integer|exists:events,id',
'events.*.active' => 'sometimes|boolean',
'events.*.run_order' => 'nullable|integer',
'events.*.config' => 'nullable|array',
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
'events.*.config.deactivate_previous' => 'sometimes|boolean',
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
'events.*.config.reactivate' => 'sometimes|boolean',
'events.*.config.conditions' => 'nullable|array',
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
]); ]);
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray(); $actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
$eventsPayload = collect($attributes['events'] ?? []);
\DB::transaction(function () use ($attributes, $actionIds) { // Extra server-side validation for event-specific config keys
$validationErrors = [];
foreach ($eventsPayload as $i => $ev) {
$idEv = isset($ev['id']) ? (int) $ev['id'] : null;
if (! $idEv) {
continue;
}
$eventModel = \App\Models\Event::find($idEv);
$key = $eventModel?->key ?? ($ev['key'] ?? null);
if ($key === 'add_segment') {
$seg = $ev['config']['segment_id'] ?? null;
if (empty($seg)) {
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
}
} elseif ($key === 'archive_contract') {
$as = $ev['config']['archive_setting_id'] ?? null;
if (empty($as)) {
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
}
}
}
if (! empty($validationErrors)) {
throw ValidationException::withMessages($validationErrors);
}
\DB::transaction(function () use ($attributes, $actionIds, $eventsPayload) {
/** @var \App\Models\Decision $row */ /** @var \App\Models\Decision $row */
$row = Decision::create([ $row = Decision::create([
'name' => $attributes['name'], 'name' => $attributes['name'],
'color_tag' => $attributes['color_tag'] ?? null, 'color_tag' => $attributes['color_tag'] ?? null,
'auto_mail' => (bool) ($attributes['auto_mail'] ?? false),
'email_template_id' => $attributes['email_template_id'] ?? null,
]); ]);
if (!empty($actionIds)) { if (! empty($actionIds)) {
$row->actions()->sync($actionIds); $row->actions()->sync($actionIds);
} }
// Attach decision events with pivot attributes
if ($eventsPayload->isNotEmpty()) {
$sync = [];
foreach ($eventsPayload as $ev) {
$id = (int) ($ev['id'] ?? 0);
if ($id <= 0) {
continue;
}
$cfg = $ev['config'] ?? null;
if (is_array($cfg)) {
$cfg = json_encode($cfg);
} elseif (is_string($cfg)) {
$trim = trim($cfg);
$cfg = $trim === '' ? null : $cfg;
} else {
$cfg = null;
}
$sync[$id] = [
'active' => (bool) ($ev['active'] ?? true),
'run_order' => isset($ev['run_order']) ? (int) $ev['run_order'] : null,
'config' => $cfg,
];
}
$row->events()->sync($sync);
}
}); });
return to_route('settings.workflow')->with('success', 'Decision created successfully!'); return to_route('settings.workflow')->with('success', 'Decision created successfully!');
@@ -107,22 +188,102 @@ 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',
'auto_mail' => 'sometimes|boolean',
'email_template_id' => 'nullable|integer|exists:email_templates,id',
'actions' => 'nullable|array', 'actions' => 'nullable|array',
'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id', 'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id',
'actions.*.name' => 'required_with:actions.*|string|max:50', 'actions.*.name' => 'required_with:actions.*|string|max:50',
'events' => 'nullable|array',
'events.*.id' => 'required_with:events.*|integer|exists:events,id',
'events.*.active' => 'sometimes|boolean',
'events.*.run_order' => 'nullable|integer',
'events.*.config' => 'nullable|array',
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
'events.*.config.deactivate_previous' => 'sometimes|boolean',
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
'events.*.config.reactivate' => 'sometimes|boolean',
'events.*.config.conditions' => 'nullable|array',
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
]); ]);
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray(); $actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
$eventsPayload = collect($attributes['events'] ?? []);
\DB::transaction(function () use ($attributes, $actionIds, $row) { // Extra server-side validation for event-specific config keys
$validationErrors = [];
foreach ($eventsPayload as $i => $ev) {
$idEv = isset($ev['id']) ? (int) $ev['id'] : null;
if (! $idEv) {
continue;
}
$eventModel = \App\Models\Event::find($idEv);
$key = $eventModel?->key ?? ($ev['key'] ?? null);
if ($key === 'add_segment') {
$seg = $ev['config']['segment_id'] ?? null;
if (empty($seg)) {
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
}
} elseif ($key === 'archive_contract') {
$as = $ev['config']['archive_setting_id'] ?? null;
if (empty($as)) {
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
}
}
}
if (! empty($validationErrors)) {
throw ValidationException::withMessages($validationErrors);
}
\DB::transaction(function () use ($attributes, $actionIds, $eventsPayload, $row) {
$row->update([ $row->update([
'name' => $attributes['name'], 'name' => $attributes['name'],
'color_tag' => $attributes['color_tag'] ?? null, 'color_tag' => $attributes['color_tag'] ?? null,
'auto_mail' => (bool) ($attributes['auto_mail'] ?? false),
'email_template_id' => $attributes['email_template_id'] ?? null,
]); ]);
$row->actions()->sync($actionIds); $row->actions()->sync($actionIds);
// Sync decision events with pivot attributes
if ($eventsPayload->isNotEmpty()) {
$sync = [];
foreach ($eventsPayload as $ev) {
$id = (int) ($ev['id'] ?? 0);
if ($id <= 0) {
continue;
}
$cfg = $ev['config'] ?? null;
// ensure string JSON stored; accept already-JSON strings
if (is_array($cfg)) {
$cfg = json_encode($cfg);
} elseif (is_string($cfg)) {
$trim = trim($cfg);
// If not valid JSON, keep raw string (handler side can parse/ignore)
$cfg = $trim === '' ? null : $cfg;
} else {
$cfg = null;
}
$sync[$id] = [
'active' => (bool) ($ev['active'] ?? true),
'run_order' => isset($ev['run_order']) ? (int) $ev['run_order'] : null,
'config' => $cfg,
];
}
$row->events()->sync($sync);
} else {
// If empty provided explicitly, detach all to reflect UI intent
if (array_key_exists('events', $attributes)) {
$row->events()->detach();
}
}
}); });
return to_route('settings.workflow')->with('success', 'Decision updated successfully!'); return to_route('settings.workflow')->with('success', 'Decision updated successfully!');
@@ -139,6 +300,7 @@ public function destroyAction(int $id)
$row->decisions()->detach(); $row->decisions()->detach();
$row->delete(); $row->delete();
}); });
return back()->with('success', 'Action deleted successfully!'); return back()->with('success', 'Action deleted successfully!');
} }
@@ -153,6 +315,7 @@ public function destroyDecision(int $id)
$row->actions()->detach(); $row->actions()->detach();
$row->delete(); $row->delete();
}); });
return back()->with('success', 'Decision deleted successfully!'); return back()->with('success', 'Decision deleted successfully!');
} }
} }
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsActive
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = Auth::user();
if ($user && ! $user->active) {
// Revoke all tokens for Sanctum
if (method_exists($user, 'tokens')) {
$user->tokens()->delete();
}
// Logout from web guard
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
if ($request->expectsJson()) {
return response()->json(['message' => 'Vaš račun je bil onemogočen.'], 403);
}
return redirect()->route('login')->with('error', 'Vaš račun je bil onemogočen.');
}
return $next($request);
}
}
+97 -9
View File
@@ -57,7 +57,17 @@ public function share(Request $request): array
'error' => fn () => $request->session()->get('error'), 'error' => fn () => $request->session()->get('error'),
'warning' => fn () => $request->session()->get('warning'), 'warning' => fn () => $request->session()->get('warning'),
'info' => fn () => $request->session()->get('info'), 'info' => fn () => $request->session()->get('info'),
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
], ],
'callLaterCount' => function () use ($request) {
if (! $request->user()) {
return 0;
}
return \App\Models\CallLater::query()
->whereNull('completed_at')
->count();
},
'notifications' => function () use ($request) { 'notifications' => function () use ($request) {
try { try {
$user = $request->user(); $user = $request->user();
@@ -66,21 +76,99 @@ public function share(Request $request): array
} }
$today = now()->toDateString(); $today = now()->toDateString();
// Base fetch to avoid serialization issues; eager load relations afterwards
$activities = \App\Models\Activity::query() $activities = \App\Models\Activity::query()
->with([ ->select(['id', 'due_date', 'amount', 'contract_id', 'client_case_id', 'created_at'])
// Include contract uuid and reference, keep id for relation mapping, and client_case_id for nested eager load
'contract:id,uuid,reference,client_case_id',
// Include client case uuid (id required for mapping, will be hidden in JSON)
'contract.clientCase:id,uuid',
// Include account amounts; contract_id needed for relation mapping
'contract.account:contract_id,balance_amount,initial_amount',
])
->whereDate('due_date', $today) ->whereDate('due_date', $today)
->where('user_id', $user->id) // Exclude activities that have been marked as read by this user
->whereNotExists(function ($q) use ($user, $today) {
$q->select(\DB::raw(1))
->from('activity_notification_reads')
->whereColumn('activity_notification_reads.activity_id', 'activities.id')
->where('activity_notification_reads.user_id', $user->id)
->whereDate('activity_notification_reads.due_date', '<=', $today)
->whereNotNull('activity_notification_reads.read_at');
})
->orderBy('created_at') ->orderBy('created_at')
->limit(20) ->limit(20)
->get(); ->get();
// Eager load needed relations (contracts and client cases) with qualified selects
$activities->load([
'contract' => function ($q) {
$q->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.client_case_id'])
->with([
// Include client (via case) so the UI can render client.person.full_name
'clientCase' => function ($qq) {
// Include person_id to ensure nested person loads correctly and to avoid null clientCase due to narrow selects
$qq->select(['client_cases.id', 'client_cases.uuid', 'client_cases.client_id', 'client_cases.person_id'])
->with([
'client' => function ($qqq) {
$qqq->select(['clients.id', 'clients.person_id'])
->with([
'person' => function ($qqqq) {
$qqqq->select(['person.id', 'person.full_name']);
},
]);
},
]);
},
'account' => function ($qq) {
$qq->select(['accounts.id', 'accounts.contract_id', 'accounts.balance_amount', 'accounts.initial_amount']);
},
]);
},
'clientCase' => function ($q) {
$q->select(['client_cases.id', 'client_cases.uuid', 'client_cases.person_id', 'client_cases.client_id'])
->with([
'person' => function ($qq) {
$qq->select(['person.id', 'person.full_name']);
},
'client' => function ($qq) {
$qq->select(['clients.id', 'clients.person_id'])
->with([
'person' => function ($qqq) {
$qqq->select(['person.id', 'person.full_name']);
},
]);
},
]);
},
]);
// For convenience on the frontend, mirror client onto the contract so it can be accessed as contract.client.person
// 1) Build a map of contract_id -> client_id using a lightweight join
$contractIds = $activities->pluck('contract_id')->filter()->unique()->values();
if ($contractIds->isNotEmpty()) {
$mapContractToClient = \App\Models\Contract::query()
->whereIn('contracts.id', $contractIds)
->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
->pluck('client_cases.client_id', 'contracts.id');
// 2) Load all needed clients with their person
$clientIds = $mapContractToClient->filter()->unique()->values();
$clientsById = $clientIds->isNotEmpty()
? \App\Models\Client::query()
->whereIn('clients.id', $clientIds)
->with(['person:id,full_name'])
->get(['clients.id', 'clients.uuid', 'clients.person_id'])
->keyBy('id')
: collect();
// 3) Attach client relation on each contract instance
foreach ($activities as $act) {
$contract = $act->getRelation('contract');
if (! $contract) {
continue;
}
$cid = $mapContractToClient->get($contract->id);
if ($cid && $clientsById->has($cid)) {
$contract->setRelation('client', $clientsById->get($cid));
}
}
}
return [ return [
'dueToday' => [ 'dueToday' => [
'count' => $activities->count(), 'count' => $activities->count(),
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rules\Password;
class StoreUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return Gate::allows('manage-settings');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', Password::defaults(), 'confirmed'],
'roles' => ['array'],
'roles.*' => ['integer', 'exists:roles,id'],
];
}
/**
* Get custom error messages.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'name.required' => 'Ime uporabnika je obvezno.',
'email.required' => 'E-poštni naslov je obvezen.',
'email.email' => 'E-poštni naslov mora biti veljaven.',
'email.unique' => 'Ta e-poštni naslov je že v uporabi.',
'password.required' => 'Geslo je obvezno.',
'password.confirmed' => 'Gesli se ne ujemata.',
'roles.*.exists' => 'Izbrana vloga ni veljavna.',
];
}
}
@@ -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,42 @@
<?php
namespace App\Http\Requests;
use App\Exports\SegmentContractsExport;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ExportSegmentContractsRequest 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(SegmentContractsExport::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'],
'client' => ['nullable', 'string', 'max:64'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'client' => $this->input('client') ?? $this->input('client_id'),
'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'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEmailTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create', \App\Models\EmailTemplate::class) ?? false;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'key' => ['required', 'string', 'max:255', 'unique:email_templates,key'],
'subject_template' => ['required', 'string', 'max:1000'],
'html_template' => ['nullable', 'string'],
'text_template' => ['nullable', 'string'],
'entity_types' => ['nullable', 'array'],
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
'allow_attachments' => ['sometimes', 'boolean'],
'active' => ['boolean'],
'client' => ['sometimes', 'boolean'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreInstallmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'amount' => ['required', 'numeric', 'min:0.01'],
'currency' => ['nullable', 'string', 'size:3'],
'reference' => ['nullable', 'string', 'max:100'],
'installment_at' => ['nullable', 'date'],
'meta' => ['nullable', 'array'],
];
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreMailProfileRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() && $this->user()->can('create', \App\Models\MailProfile::class);
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:190'],
'host' => ['required', 'string', 'max:190'],
'port' => ['required', 'integer', 'between:1,65535'],
'encryption' => ['nullable', 'in:ssl,tls,starttls'],
'username' => ['nullable', 'string', 'max:190'],
'password' => ['required', 'string', 'max:512'],
'from_address' => ['required', 'email', 'max:190'],
'from_name' => ['nullable', 'string', 'max:190'],
'reply_to_address' => ['nullable', 'email', 'max:190'],
'reply_to_name' => ['nullable', 'string', 'max:190'],
'priority' => ['nullable', 'integer', 'between:0,65535'],
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
'signature' => ['nullable', 'array'],
'signature.*' => ['nullable', 'string', 'max:1000'],
'auto_mailer' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePackageFromContractsRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'type' => ['required', 'in:sms'],
'name' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'meta' => ['nullable', 'array'],
// Common payload for all items
'payload' => ['required', 'array'],
'payload.profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
'payload.sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
'payload.template_id' => ['nullable', 'integer', 'exists:sms_templates,id'],
'payload.delivery_report' => ['nullable', 'boolean'],
'payload.variables' => ['nullable', 'array'],
'payload.body' => ['nullable', 'string'],
// Source contracts to derive items from
'contract_ids' => ['required', 'array', 'min:1'],
'contract_ids.*' => ['integer', 'exists:contracts,id'],
];
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePackageRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'type' => ['required', 'in:sms'],
'name' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'meta' => ['nullable', 'array'],
// items
'items' => ['required', 'array', 'min:1'],
'items.*.number' => ['required', 'string'],
'items.*.phone_id' => ['nullable', 'integer'],
'items.*.payload' => ['nullable', 'array'],
'items.*.payload.profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
'items.*.payload.sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
'items.*.payload.template_id' => ['nullable', 'integer', 'exists:sms_templates,id'],
'items.*.payload.delivery_report' => ['nullable', 'boolean'],
'items.*.payload.variables' => ['nullable', 'array'],
'items.*.payload.body' => ['nullable', 'string'],
];
}
}
@@ -17,6 +17,8 @@ public function rules(): array
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', 'unique:permissions,slug'], 'slug' => ['required', 'string', 'max:255', 'alpha_dash', 'unique:permissions,slug'],
'description' => ['nullable', 'string', 'max:500'], 'description' => ['nullable', 'string', 'max:500'],
'roles' => ['sometimes', 'array'],
'roles.*' => ['integer', 'exists:roles,id'],
]; ];
} }
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreSmsProfileRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:190'],
'active' => ['sometimes', 'boolean'],
'api_username' => ['required', 'string', 'max:190'],
'api_password' => ['required', 'string', 'max:500'],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreSmsSenderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
$pid = (int) $this->input('profile_id');
return [
'profile_id' => ['required', 'integer', 'exists:sms_profiles,id'],
'sname' => [
'nullable', 'string', 'max:20',
Rule::unique('sms_senders', 'sname')->where(fn ($q) => $q->where('profile_id', $pid)),
],
'phone_number' => ['nullable', 'string', 'max:30'],
'description' => ['nullable', 'string', 'max:190'],
'active' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreSmsTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:190'],
'slug' => ['required', 'string', 'max:190', 'alpha_dash', 'unique:sms_templates,slug'],
// Content is required unless template allows custom body
'content' => [Rule::requiredIf(fn () => ! (bool) $this->input('allow_custom_body')), 'nullable', 'string', 'max:1000'],
'variables_json' => ['nullable', 'array'],
'is_active' => ['sometimes', 'boolean'],
'default_profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
'default_sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
'allow_custom_body' => ['sometimes', 'boolean'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class TestSendSmsRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'to' => ['required', 'string', 'max:30'], // E.164-ish; we can refine later
'message' => ['required', 'string', 'max:1000'],
'sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
'delivery_report' => ['sometimes', 'boolean'],
'country_code' => ['nullable', 'string', 'max:5'],
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class TestSendSmsTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'to' => ['required', 'string', 'max:30'],
'variables' => ['nullable', 'array'],
'profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
'sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
'delivery_report' => ['sometimes', 'boolean'],
'country_code' => ['nullable', 'string', 'max:5'],
'custom_content' => ['nullable', 'string', 'max:1000'],
];
}
}
@@ -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'],
];
}
}
@@ -30,6 +30,10 @@ public function rules(): array
'date_formats.*' => ['nullable', 'string', 'max:40'], 'date_formats.*' => ['nullable', 'string', 'max:40'],
'meta' => ['sometimes', 'array'], 'meta' => ['sometimes', 'array'],
'meta.*' => ['nullable'], 'meta.*' => ['nullable'],
'meta.custom_defaults' => ['nullable', 'array'],
'meta.custom_defaults.*' => ['nullable'],
'meta.custom_default_types' => ['nullable', 'array'],
'meta.custom_default_types.*' => ['nullable', 'in:string,number,date,text'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'], 'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'], 'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
'activity_note_template' => ['nullable', 'string'], 'activity_note_template' => ['nullable', 'string'],
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEmailTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('update', $this->route('emailTemplate')) ?? false;
}
public function rules(): array
{
$id = $this->route('emailTemplate')?->id;
return [
'name' => ['required', 'string', 'max:255'],
'key' => ['required', 'string', 'max:255', 'unique:email_templates,key,'.$id],
'subject_template' => ['required', 'string', 'max:1000'],
'html_template' => ['nullable', 'string'],
'text_template' => ['nullable', 'string'],
'entity_types' => ['nullable', 'array'],
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
'allow_attachments' => ['sometimes', 'boolean'],
'active' => ['boolean'],
'client' => ['sometimes', 'boolean'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateInstallmentSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'default_currency' => ['required', 'string', 'size:3'],
'create_activity_on_installment' => ['sometimes', 'boolean'],
'default_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
'default_action_id' => ['nullable', 'integer', 'exists:actions,id'],
'activity_note_template' => ['nullable', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateMailProfileRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() && $this->user()->can('update', $this->route('mail_profile'));
}
public function rules(): array
{
return [
'name' => ['sometimes', 'required', 'string', 'max:190'],
'host' => ['sometimes', 'required', 'string', 'max:190'],
'port' => ['sometimes', 'required', 'integer', 'between:1,65535'],
'encryption' => ['nullable', 'in:ssl,tls,starttls'],
'username' => ['nullable', 'string', 'max:190'],
'password' => ['nullable', 'string', 'max:512'],
'from_address' => ['sometimes', 'required', 'email', 'max:190'],
'from_name' => ['nullable', 'string', 'max:190'],
'reply_to_address' => ['nullable', 'email', 'max:190'],
'reply_to_name' => ['nullable', 'string', 'max:190'],
'priority' => ['nullable', 'integer', 'between:0,65535'],
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
'active' => ['nullable', 'boolean'],
'signature' => ['nullable', 'array'],
'signature.*' => ['nullable', 'string', 'max:1000'],
'auto_mailer' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePermissionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasPermission('manage-settings') || $this->user()?->hasRole('admin');
}
public function rules(): array
{
$permissionId = $this->route('permission')->id ?? null;
return [
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('permissions', 'slug')->ignore($permissionId)],
'description' => ['nullable', 'string', 'max:500'],
'roles' => ['sometimes', 'array'],
'roles.*' => ['integer', 'exists:roles,id'],
];
}
public function messages(): array
{
return [
'name.required' => 'Ime je obvezno.',
'slug.required' => 'Slug je obvezen.',
'slug.unique' => 'Slug že obstaja.',
];
}
}
@@ -17,6 +17,7 @@ public function rules(): array
'name' => ['required', 'string', 'max:50'], 'name' => ['required', 'string', 'max:50'],
'description' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:255'],
'active' => ['boolean'], 'active' => ['boolean'],
'exclude' => ['boolean'],
]; ];
} }
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateSmsSenderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
$pid = (int) $this->input('profile_id');
$id = (int) ($this->route('smsSender')?->id ?? 0);
return [
'profile_id' => ['required', 'integer', 'exists:sms_profiles,id'],
'sname' => [
'nullable', 'string', 'max:20',
Rule::unique('sms_senders', 'sname')
->ignore($id)
->where(fn ($q) => $q->where('profile_id', $pid)),
],
'phone_number' => ['nullable', 'string', 'max:30'],
'description' => ['nullable', 'string', 'max:190'],
'active' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateSmsTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
$id = (int) ($this->route('smsTemplate')?->id ?? 0);
return [
'name' => ['required', 'string', 'max:190'],
'slug' => ['required', 'string', 'max:190', 'alpha_dash', Rule::unique('sms_templates', 'slug')->ignore($id)],
// Content is required unless template allows custom body
'content' => [Rule::requiredIf(fn () => ! (bool) $this->input('allow_custom_body')), 'nullable', 'string', 'max:1000'],
'variables_json' => ['nullable', 'array'],
'is_active' => ['sometimes', 'boolean'],
'default_profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
'default_sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
'allow_custom_body' => ['sometimes', 'boolean'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
];
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ActivityCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
// Transform data to add user_name attribute
$this->collection->transform(function ($activity) {
$activity->setAttribute('user_name', optional($activity->user)->name);
return $activity;
});
return $this->resource->toArray();
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ContractCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
return $this->resource->toArray();
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class DocumentCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
];
}
}

Some files were not shown because too many files have changed in this diff Show More