93 Commits

Author SHA1 Message Date
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č 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
490 changed files with 45242 additions and 16017 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
+20
View File
@@ -19,3 +19,23 @@ yarn-error.log
/.idea
/.vscode
/.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();
$months = $data->pluck('month')->map(
fn($nu)
=> \DateTime::createFromFormat('!m', $nu)->format('F'))->toArray();
fn ($nu) => \DateTime::createFromFormat('!m', $nu)->format('F'))->toArray();
$newCases = $data->pluck('count')->toArray();
return $this->chart->areaChart()
->setTitle('Novi primeri zadnjih šest mesecev.')
->addData('Primeri', $newCases)
//->addData('Completed', [7, 2, 7, 2, 5, 4])
// ->addData('Completed', [7, 2, 7, 2, 5, 4])
->setColors(['#ff6384'])
->setXAxis($months)
->setToolbar(true)
@@ -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;
use Illuminate\Console\Command;
use App\Models\Post;
use Illuminate\Console\Command;
class ImportPosts extends Command
{
protected $signature = 'import:posts';
protected $description = 'Import posts into Algolia without clearing the index';
public function __construct()
@@ -22,4 +23,3 @@ public function handle()
$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
{
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.';
public function handle(): int
{
$days = (int) $this->option('days');
if ($days < 1) { $days = 90; }
if ($days < 1) {
$days = 90;
}
$cutoff = Carbon::now()->subDays($days);
$previewDisk = config('files.preview_disk', 'public');
@@ -27,6 +30,7 @@ public function handle(): int
$count = $query->count();
if ($count === 0) {
$this->info('No stale previews found.');
return self::SUCCESS;
}
@@ -36,9 +40,12 @@ public function handle(): int
$query->chunkById(200, function ($docs) use ($previewDisk, $dry) {
foreach ($docs as $doc) {
$path = $doc->preview_path;
if (!$path) { continue; }
if (! $path) {
continue;
}
if ($dry) {
$this->line("Would delete: {$previewDisk}://{$path} (document #{$doc->id})");
continue;
}
try {
@@ -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,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;
}
}
}
+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;
}
}
@@ -2,10 +2,6 @@
namespace App\Http\Controllers;
use App\Models\Account;
use Illuminate\Http\Request;
use Inertia\Inertia;
class AccountController extends Controller
{
//
@@ -13,8 +13,10 @@ class ActivityNotificationController extends Controller
*/
public function __invoke(Request $request)
{
$request->validate([
'activity_id' => ['required', 'integer', 'exists:activities,id'],
$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;
@@ -22,20 +24,30 @@ public function __invoke(Request $request)
abort(403);
}
$activity = Activity::query()->select(['id', 'due_date'])->findOrFail($request->integer('activity_id'));
$due = optional($activity->due_date) ? date('Y-m-d', strtotime($activity->due_date)) : now()->toDateString();
$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);
ActivityNotificationRead::query()->updateOrCreate(
[
'user_id' => $userId,
'activity_id' => $activity->id,
'due_date' => $due,
],
[
'read_at' => now(),
]
);
$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 response()->json(['status' => 'ok']);
return back();
}
}
+128 -25
View File
@@ -12,6 +12,7 @@
use App\Models\SmsTemplate;
use App\Services\Contact\PhoneSelector;
use App\Services\Sms\SmsService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus;
@@ -23,9 +24,19 @@ class PackageController extends Controller
{
public function index(Request $request): Response
{
$perPage = $request->input('per_page') ?? 25;
$packages = Package::query()
->latest('id')
->paginate(20);
->paginate($perPage);
return Inertia::render('Admin/Packages/Index', [
'packages' => $packages,
]);
}
public function create(Request $request): Response
{
// Minimal lookups for create form (active only)
$profiles = \App\Models\SmsProfile::query()
->where('active', true)
@@ -37,9 +48,10 @@ public function index(Request $request): Response
->get(['id', 'profile_id', 'sname', 'phone_number']);
$templates = \App\Models\SmsTemplate::query()
->orderBy('name')
->get(['id', '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
@@ -58,8 +70,7 @@ public function index(Request $request): Response
})
->values();
return Inertia::render('Admin/Packages/Index', [
'packages' => $packages,
return Inertia::render('Admin/Packages/Create', [
'profiles' => $profiles,
'senders' => $senders,
'templates' => $templates,
@@ -98,6 +109,10 @@ public function show(Package $package, SmsService $sms): Response
'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;
@@ -121,7 +136,7 @@ public function show(Package $package, SmsService $sms): Response
if (! $rendered) {
$body = isset($payload['body']) ? trim((string) $payload['body']) : '';
if ($body !== '') {
$rendered = $body;
$rendered = $sms->renderContent($body, $vars);
} elseif (! empty($payload['template_id'])) {
$tpl = \App\Models\SmsTemplate::find((int) $payload['template_id']);
if ($tpl) {
@@ -157,6 +172,10 @@ public function show(Package $package, SmsService $sms): Response
'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;
@@ -175,7 +194,7 @@ public function show(Package $package, SmsService $sms): Response
if ($body !== '') {
$preview = [
'source' => 'body',
'content' => $body,
'content' => $sms->renderContent($body, $vars),
];
} elseif (! empty($payload['template_id'])) {
/** @var SmsTemplate|null $tpl */
@@ -215,6 +234,8 @@ public function store(StorePackageRequest $request): RedirectResponse
'created_by' => optional($request->user())->id,
]);
dd($data['items']);
$items = collect($data['items'])
->map(function (array $row) {
return new PackageItem([
@@ -300,30 +321,47 @@ public function destroy(Package $package): RedirectResponse
public function contracts(Request $request, PhoneSelector $selector): \Illuminate\Http\JsonResponse
{
$request->validate([
'segment_id' => ['required', 'integer', 'exists:segments,id'],
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'q' => ['nullable', 'string'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'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 = (int) $request->input('segment_id');
$perPage = (int) ($request->input('per_page') ?? 25);
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
$query = Contract::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);
})
->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}%");
@@ -335,6 +373,30 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
->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) {
@@ -347,18 +409,21 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
});
}
$contracts = $query->paginate($perPage);
$contracts = $query->limit(500)->get();
$data = collect($contracts->items())->map(function (Contract $contract) use ($selector) {
$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,
@@ -369,6 +434,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
'uuid' => $person?->uuid,
'full_name' => $person?->full_name,
],
'segment' => $segment,
// Stranka: the client person
'client' => $clientPerson ? [
'id' => $contract->clientCase?->client?->id,
@@ -387,12 +453,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
return response()->json([
'data' => $data,
'meta' => [
'current_page' => $contracts->currentPage(),
'last_page' => $contracts->lastPage(),
'per_page' => $contracts->perPage(),
'total' => $contracts->total(),
],
]);
}
@@ -428,12 +488,12 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
continue;
}
$key = $phone->id ? 'id:'.$phone->id : 'num:'.$phone->nu;
if ($seen->contains($key)) {
/*if ($seen->contains($key)) {
// skip duplicates across multiple contracts/persons
$skipped++;
continue;
}
}*/
$seen->push($key);
$items[] = [
'number' => (string) $phone->nu,
@@ -481,4 +541,47 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
return back()->with('success', '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;
}
}
@@ -3,12 +3,14 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreUserRequest;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
use Inertia\Inertia;
use Inertia\Response;
@@ -18,7 +20,7 @@ public function index(Request $request): Response
{
Gate::authorize('manage-settings');
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email']);
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active']);
$roles = Role::with('permissions:id,slug,name')->orderBy('name')->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
{
Gate::authorize('manage-settings');
@@ -42,4 +61,16 @@ public function update(Request $request, User $user): RedirectResponse
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}");
}
}
@@ -5,7 +5,6 @@
use App\Models\CaseObject;
use App\Models\ClientCase;
use App\Models\Contract;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
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)
{
$object = CaseObject::where('id', $id)
->whereHas('contract', fn($q) => $q->where('client_case_id', $clientCase->id))
$object = CaseObject::where('id', $id)
->whereHas('contract', fn ($q) => $q->where('client_case_id', $clientCase->id))
->firstOrFail();
$validated = $request->validate([
@@ -45,8 +44,8 @@ public function update(ClientCase $clientCase, int $id, Request $request)
public function destroy(ClientCase $clientCase, int $id)
{
$object = CaseObject::where('id', $id)
->whereHas('contract', fn($q) => $q->where('client_case_id', $clientCase->id))
$object = CaseObject::where('id', $id)
->whereHas('contract', fn ($q) => $q->where('client_case_id', $clientCase->id))
->firstOrFail();
$object->delete();
+462 -190
View File
@@ -4,15 +4,18 @@
use App\Http\Requests\StoreContractRequest;
use App\Http\Requests\UpdateContractRequest;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Document;
use App\Models\Segment;
use App\Services\Documents\DocumentStreamService;
use App\Services\ReferenceDataCache;
use App\Services\Sms\SmsService;
use Exception;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
@@ -30,6 +33,16 @@ public function __construct(
public function index(ClientCase $clientCase, Request $request)
{
$search = $request->input('search');
$from = $this->normalizeDate($request->input('from'));
$to = $this->normalizeDate($request->input('to'));
$clientFilter = collect(explode(',', (string) $request->input('clients')))
->filter()
->map(fn ($value) => (int) $value)
->filter(fn ($value) => $value > 0)
->unique()
->values();
$perPage = $this->resolvePerPage($request);
$query = $clientCase::query()
->select('client_cases.*')
@@ -39,7 +52,6 @@ public function index(ClientCase $clientCase, Request $request)
->groupBy('client_cases.id');
})
->where('client_cases.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('contracts', function ($join) {
$join->on('contracts.client_case_id', '=', 'client_cases.id')
->whereNull('contracts.deleted_at');
@@ -49,11 +61,18 @@ public function index(ClientCase $clientCase, Request $request)
->where('contract_segment.active', true);
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->when($clientFilter->isNotEmpty(), function ($que) use ($clientFilter) {
$que->whereIn('client_cases.client_id', $clientFilter->all());
})
->when($from, function ($que) use ($from) {
$que->whereDate('client_cases.created_at', '>=', $from);
})
->when($to, function ($que) use ($to) {
$que->whereDate('client_cases.created_at', '<=', $to);
})
->groupBy('client_cases.id')
->addSelect([
// Count of active contracts (a contract is considered active if it has an active pivot in contract_segment)
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
// Sum of balances for accounts of active contracts
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->with(['person.client', 'client.person'])
@@ -61,12 +80,49 @@ public function index(ClientCase $clientCase, Request $request)
return Inertia::render('Cases/Index', [
'client_cases' => $query
->paginate($request->integer('perPage', 15), ['*'], 'clientCasesPage')
->paginate($perPage, ['*'], 'clientCasesPage')
->withQueryString(),
'filters' => $request->only(['search']),
'filters' => [
'search' => $search,
'from' => $from,
'to' => $to,
'clients' => $clientFilter->map(fn ($value) => (string) $value)->all(),
'perPage' => $perPage,
],
'clients' => Client::query()
->select(['clients.id', 'person.full_name as name'])
->join('person', 'person.id', '=', 'clients.person_id')
->orderBy('person.full_name')
->get()
->map(fn ($client) => [
'id' => (int) $client->id,
'name' => (string) ($client->name ?? ''),
]),
]);
}
private function resolvePerPage(Request $request): int
{
$allowed = [10, 15, 25, 50, 100];
$perPage = (int) $request->integer('perPage', 15);
return in_array($perPage, $allowed, true) ? $perPage : 15;
}
private function normalizeDate(?string $value): ?string
{
if (! $value) {
return null;
}
try {
return Carbon::parse($value)->toDateString();
} catch (\Throwable) {
return null;
}
}
/**
* Show the form for creating a new resource.
*/
@@ -255,53 +311,113 @@ public function storeActivity(ClientCase $clientCase, Request $request)
'action_id' => 'exists:\App\Models\Action,id',
'decision_id' => 'exists:\App\Models\Decision,id',
'contract_uuid' => 'nullable|uuid',
'contract_uuids' => 'nullable|array',
'contract_uuids.*' => 'uuid',
'create_for_all_contracts' => 'nullable|boolean',
'phone_view' => 'nullable|boolean',
'send_auto_mail' => 'sometimes|boolean',
'attachment_document_ids' => 'sometimes|array',
'attachment_document_ids.*' => 'integer',
]);
// Map contract_uuid to contract_id within the same client case, if provided
$contractId = null;
if (! empty($attributes['contract_uuid'])) {
$isPhoneView = $attributes['phone_view'] ?? false;
$createForAll = $attributes['create_for_all_contracts'] ?? false;
$contractUuids = $attributes['contract_uuids'] ?? [];
// Determine which contracts to process
$contractIds = [];
if ($createForAll && !empty($contractUuids)) {
// Get all contract IDs from the provided UUIDs
$contracts = Contract::withTrashed()
->whereIn('uuid', $contractUuids)
->where('client_case_id', $clientCase->id)
->get();
$contractIds = $contracts->pluck('id')->toArray();
} elseif (!empty($contractUuids) && isset($contractUuids[0])) {
// Single contract mode
$contract = Contract::withTrashed()
->where('uuid', $contractUuids[0])
->where('client_case_id', $clientCase->id)
->first();
if ($contract) {
$contractIds = [$contract->id];
}
} elseif (!empty($attributes['contract_uuid'])) {
// Legacy single contract_uuid support
$contract = Contract::withTrashed()
->where('uuid', $attributes['contract_uuid'])
->where('client_case_id', $clientCase->id)
->first();
if ($contract) {
// Archived contracts are allowed: link activity regardless of active flag
$contractId = $contract->id;
$contractIds = [$contract->id];
}
}
// Create activity
$row = $clientCase->activities()->create([
'due_date' => $attributes['due_date'] ?? null,
'amount' => $attributes['amount'] ?? null,
'note' => $attributes['note'] ?? null,
'action_id' => $attributes['action_id'],
'decision_id' => $attributes['decision_id'],
'contract_id' => $contractId,
]);
/*foreach ($activity->decision->events as $e) {
$class = '\\App\\Events\\' . $e->name;
event(new $class($clientCase));
}*/
// If no contracts specified, create a single activity without contract
if (empty($contractIds)) {
$contractIds = [null];
}
logger()->info('Activity successfully inserted', $attributes);
$createdActivities = [];
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
// Auto mail dispatch (best-effort)
try {
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
// Filter attachments to those belonging to the selected contract
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
->filter()
->map(fn ($v) => (int) $v)
->values();
$validAttachmentIds = collect();
if ($attachmentIds->isNotEmpty() && $contractId) {
$validAttachmentIds = \App\Models\Document::query()
->where('documentable_type', \App\Models\Contract::class)
// Disable auto mail if creating activities for multiple contracts
if ($sendFlag && count($contractIds) > 1) {
$sendFlag = false;
logger()->info('Auto mail disabled: multiple contracts selected', ['contract_count' => count($contractIds)]);
}
foreach ($contractIds as $contractId) {
// Create activity
$row = $clientCase->activities()->create([
'due_date' => $attributes['due_date'] ?? null,
'amount' => $attributes['amount'] ?? null,
'note' => $attributes['note'] ?? null,
'action_id' => $attributes['action_id'],
'decision_id' => $attributes['decision_id'],
'contract_id' => $contractId,
]);
$createdActivities[] = $row;
if ($isPhoneView && $contractId) {
$contract = Contract::find($contractId);
if ($contract) {
$fieldJob = $contract->fieldJobs()
->whereNull('completed_at')
->whereNull('cancelled_at')
->where('assigned_user_id', \Auth::id())
->orderByDesc('id')
->first();
if ($fieldJob) {
$fieldJob->update([
'added_activity' => true,
'last_activity' => $row->created_at,
]);
}
}
}
logger()->info('Activity successfully inserted', array_merge($attributes, ['contract_id' => $contractId]));
// Auto mail dispatch (best-effort)
try {
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
// Filter attachments to those belonging to the selected contract
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
->filter()
->map(fn ($v) => (int) $v)
->values();
$validAttachmentIds = collect();
if ($attachmentIds->isNotEmpty() && $contractId) {
$validAttachmentIds = Document::query()
->where('documentable_type', Contract::class)
->where('documentable_id', $contractId)
->whereIn('id', $attachmentIds)
->pluck('id');
$validAttachmentIds = Document::query()
->where('documentable_type', Contract::class)
->where('documentable_id', $contractId)
->whereIn('id', $attachmentIds)
->pluck('id');
@@ -311,19 +427,25 @@ public function storeActivity(ClientCase $clientCase, Request $request)
]);
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
// If template requires contract and user attempted to send, surface a validation message
return back()->with('warning', 'Email not queued: required contract is missing for the selected template.');
logger()->warning('Email not queued: required contract is missing for the selected template.');
}
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
return back()->with('warning', 'Email not queued: no eligible client emails to receive auto mails.');
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
}
} catch (\Throwable $e) {
// Do not fail activity creation due to mailing issues
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
}
}
$activityCount = count($createdActivities);
$successMessage = $activityCount > 1
? "Successfully created {$activityCount} activities!"
: 'Successfully created activity!';
// Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route.
// Use 303 to align with Inertia's recommended POST/Redirect/GET behavior.
return back(303)->with('success', 'Successful created!')->with('flash_method', 'POST');
return back(303)->with('success', $successMessage)->with('flash_method', 'POST');
} catch (QueryException $e) {
logger()->error('Database error occurred:', ['error' => $e->getMessage()]);
@@ -399,6 +521,21 @@ public function updateContractSegment(ClientCase $clientCase, string $uuid, Requ
return back()->with('success', 'Contract segment updated.')->with('flash_method', 'PATCH');
}
public function patchContractMeta(ClientCase $clientCase, string $uuid, Request $request)
{
$validated = $request->validate([
'meta' => ['required', 'array'],
]);
$contract = $clientCase->contracts()->where('uuid', $uuid)->firstOrFail();
$contract->update([
'meta' => $validated['meta'],
]);
return back()->with('success', __('Meta podatki so bili posodobljeni.'));
}
public function attachSegment(ClientCase $clientCase, Request $request)
{
$validated = $request->validate([
@@ -831,178 +968,265 @@ public function archiveContract(ClientCase $clientCase, string $uuid, Request $r
{
$contract = Contract::query()->where('uuid', $uuid)->firstOrFail();
if ($contract->client_case_id !== $clientCase->id) {
\Log::warning('Contract not found uuid: {uuid}', ['uuid' => $uuid]);
abort(404);
}
$reactivateRequested = (bool) $request->boolean('reactivate');
// Determine applicable settings based on intent (archive vs reactivate)
if ($reactivateRequested) {
$latestReactivate = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->where('reactivate', true)
->whereIn('strategy', ['immediate', 'manual'])
->orderByDesc('id')
->first();
if (! $latestReactivate) {
return back()->with('warning', __('contracts.reactivate_not_allowed'));
}
$settings = collect([$latestReactivate]);
$hasReactivateRule = true;
} else {
$settings = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->whereIn('strategy', ['immediate', 'manual'])
->where(function ($q) { // exclude reactivate-only rules from archive run
$q->whereNull('reactivate')->orWhere('reactivate', false);
})
->get();
if ($settings->isEmpty()) {
return back()->with('warning', __('contracts.no_archive_settings'));
}
$hasReactivateRule = false;
$attr = $request->validate([
'reactivate' => 'boolean',
]);
$reactivate = $attr['reactivate'] ?? false;
$setting = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->whereIn('strategy', ['immediate', 'manual'])
->where('reactivate', $reactivate)
->orderByDesc('id')
->first();
if (! $setting->exists()) {
\Log::warning('No archive settings found!');
return back()->with('warning', 'No settings found');
}
// Service archive executor
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
$result = null;
$context = [
'contract_id' => $contract->id,
'client_case_id' => $clientCase->id,
'account_id' => $contract->account->id ?? null,
];
if ($contract->account) {
$context['account_id'] = $contract->account->id;
try {
$result = $executor->executeSetting($setting, $context, \Auth::id());
} catch (Exception $e) {
\Log::error('There was an error executing ArchiveExecutor::executeSetting {msg}', ['msg' => $e->getMessage()]);
return back()->with('warning', 'Something went wrong!');
}
$overall = [];
$hadAnyEffect = false;
foreach ($settings as $setting) {
$res = $executor->executeSetting($setting, $context, optional($request->user())->id);
foreach ($res as $table => $count) {
$overall[$table] = ($overall[$table] ?? 0) + $count;
if ($count > 0) {
$hadAnyEffect = true;
}
}
}
if ($reactivateRequested && $hasReactivateRule) {
// Reactivation path: ensure contract becomes active and soft-delete cleared.
if ($contract->active == 0 || $contract->deleted_at) {
$contract->forceFill(['active' => 1, 'deleted_at' => null])->save();
$overall['contracts_reactivated'] = ($overall['contracts_reactivated'] ?? 0) + 1;
$hadAnyEffect = true;
}
} else {
// Ensure the contract itself is archived even if rule conditions would have excluded it
if (! empty($contract->getAttributes()) && $contract->active) {
if (! array_key_exists('contracts', $overall)) {
$contract->update(['active' => 0]);
$overall['contracts'] = ($overall['contracts'] ?? 0) + 1;
} else {
$contract->refresh();
}
$hadAnyEffect = true;
}
}
// Create an Activity record logging this archive if an action or decision is tied to any setting
if ($hadAnyEffect) {
$activitySetting = $settings->first(fn ($s) => ! is_null($s->action_id) || ! is_null($s->decision_id));
if ($activitySetting) {
try {
if ($reactivateRequested) {
$note = 'Ponovna aktivacija pogodba '.$contract->reference;
} else {
$noteKey = 'contracts.archived_activity_note';
$note = __($noteKey, ['reference' => $contract->reference]);
if ($note === $noteKey) {
$note = \Illuminate\Support\Facades\Lang::get($noteKey, ['reference' => $contract->reference], 'sl');
}
}
try {
\DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) {
// Create an Activity record logging this archive if an action or decision is tied to any setting
if ($setting->action_id && $setting->decision_id) {
$activityData = [
'client_case_id' => $clientCase->id,
'action_id' => $activitySetting->action_id,
'decision_id' => $activitySetting->decision_id,
'note' => $note,
'active' => 1,
'user_id' => optional($request->user())->id,
'action_id' => $setting->action_id,
'decision_id' => $setting->decision_id,
'note' => ($reactivate)
? "Ponovno aktivirana pogodba $contract->reference"
: "Arhivirana pogodba $contract->reference",
];
if ($reactivateRequested) {
// Attach the contract_id when reactivated as per requirement
$activityData['contract_id'] = $contract->id;
try {
\App\Models\Activity::create($activityData);
} catch (Exception $e) {
\Log::warning('Activity could not be created!');
}
\App\Models\Activity::create($activityData);
} catch (\Throwable $e) {
logger()->warning('Failed to create archive/reactivate activity', [
'error' => $e->getMessage(),
'contract_id' => $contract->id,
'setting_id' => optional($activitySetting)->id,
'reactivate' => $reactivateRequested,
]);
}
}
}
// If any archive setting specifies a segment_id, move the contract to that segment (archive bucket)
$segmentSetting = $settings->first(fn ($s) => ! is_null($s->segment_id)); // for reactivation this is the single reactivation setting if segment specified
if ($segmentSetting && $segmentSetting->segment_id) {
try {
$segmentId = $segmentSetting->segment_id;
\DB::transaction(function () use ($contract, $segmentId, $clientCase) {
// Ensure the segment is attached to the client case (activate if previously inactive)
$casePivot = \DB::table('client_case_segment')
->where('client_case_id', $clientCase->id)
->where('segment_id', $segmentId)
->first();
if (! $casePivot) {
\DB::table('client_case_segment')->insert([
'client_case_id' => $clientCase->id,
'segment_id' => $segmentId,
// If any archive setting specifies a segment_id, move the contract to that segment (archive bucket)
if ($setting->segment_id) {
$segmentId = $setting->segment_id;
$contract->segments()
->allRelatedIds()
->map(fn (int $val, int|string $key) => $contract->segments()->updateExistingPivot($val, [
'active' => false,
'updated_at' => now(),
])
);
if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) {
$contract->attachedSegments()->updateExistingPivot($segmentId, [
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
} elseif (! $casePivot->active) {
\DB::table('client_case_segment')
->where('id', $casePivot->id)
->update(['active' => true, 'updated_at' => now()]);
}
// Deactivate all current active contract segments
\DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('active', true)
->update(['active' => false, 'updated_at' => now()]);
// Attach or activate the archive segment for this contract
$existing = \DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('segment_id', $segmentId)
->first();
if ($existing) {
\DB::table('contract_segment')
->where('id', $existing->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(),
$contract->segments()->attach(
$segmentId,
[
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]
);
}
}
$contract->fieldJobs()
->whereNull('completed_at')
->whereNull('cancelled_at')
->update([
'cancelled_at' => date('Y-m-d'),
'updated_at' => now(),
]);
});
} catch (Exception $e) {
\Log::warning('Something went wrong with inserting / updating archive setting partials!');
return back()->with('warning', 'Something went wrong!');
}
return back()->with('success', $reactivate
? __('contracts.reactivated')
: __('contracts.archived')
);
}
/**
* Archive multiple contracts in a batch operation
*/
public function archiveBatch(Request $request)
{
$validated = $request->validate([
'contracts' => 'required|array',
'contracts.*' => 'required|uuid|exists:contracts,uuid',
'reactivate' => 'boolean',
]);
$reactivate = $validated['reactivate'] ?? false;
// Get archive setting
$setting = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->whereIn('strategy', ['immediate', 'manual'])
->where('reactivate', $reactivate)
->orderByDesc('id')
->first();
if (! $setting) {
\Log::warning('No archive settings found for batch archive');
return back()->with('flash', [
'error' => 'No archive settings found',
]);
}
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
$successCount = 0;
$skippedCount = 0;
$errors = [];
foreach ($validated['contracts'] as $contractUuid) {
try {
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
// Skip if contract is already archived (active = 0)
if (!$contract->active) {
$skippedCount++;
continue;
}
$clientCase = $contract->clientCase;
$context = [
'contract_id' => $contract->id,
'client_case_id' => $clientCase->id,
'account_id' => $contract->account->id ?? null,
];
// Execute archive setting
$executor->executeSetting($setting, $context, \Auth::id());
// Transaction for segment updates and activity logging
\DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) {
// Create activity log
if ($setting->action_id && $setting->decision_id) {
$activityData = [
'client_case_id' => $clientCase->id,
'action_id' => $setting->action_id,
'decision_id' => $setting->decision_id,
'note' => ($reactivate)
? "Ponovno aktivirana pogodba $contract->reference"
: "Arhivirana pogodba $contract->reference",
];
try {
\App\Models\Activity::create($activityData);
} catch (Exception $e) {
\Log::warning('Activity could not be created during batch archive');
}
}
// Move to archive segment if specified
if ($setting->segment_id) {
$segmentId = $setting->segment_id;
// Deactivate all current segments
$contract->segments()
->allRelatedIds()
->map(fn (int $val) => $contract->segments()->updateExistingPivot($val, [
'active' => false,
'updated_at' => now(),
]));
// Activate archive segment
if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) {
$contract->attachedSegments()->updateExistingPivot($segmentId, [
'active' => true,
'updated_at' => now(),
]);
} else {
$contract->segments()->attach($segmentId, [
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
// Cancel pending field jobs
$contract->fieldJobs()
->whereNull('completed_at')
->whereNull('cancelled_at')
->update([
'cancelled_at' => date('Y-m-d'),
'updated_at' => now(),
]);
}
});
} catch (\Throwable $e) {
logger()->warning('Failed to move contract to archive segment', [
$successCount++;
} catch (Exception $e) {
\Log::error('Error archiving contract in batch', [
'uuid' => $contractUuid,
'error' => $e->getMessage(),
'contract_id' => $contract->id,
'segment_id' => $segmentSetting->segment_id,
'setting_id' => $segmentSetting->id,
]);
$errors[] = [
'uuid' => $contractUuid,
'error' => $e->getMessage(),
];
}
}
$message = $reactivateRequested ? __('contracts.reactivated') : __('contracts.archived');
if (count($errors) > 0) {
$message = "Archived $successCount contracts";
if ($skippedCount > 0) {
$message .= ", skipped $skippedCount already archived";
}
$message .= ", " . count($errors) . " failed";
return back()->with('success', $message)->with('flash_method', 'PATCH');
return back()->with('flash', [
'error' => $message,
'details' => $errors,
]);
}
$message = $reactivate
? "Successfully reactivated $successCount contracts"
: "Successfully archived $successCount contracts";
if ($skippedCount > 0) {
$message .= " ($skippedCount already archived)";
}
return back()->with('flash', [
'success' => $message,
]);
}
/**
@@ -1016,7 +1240,7 @@ public function emergencyCreatePerson(ClientCase $clientCase, Request $request)
if ($existing && ! $existing->trashed()) {
return back()->with('flash', [
'type' => 'info',
'message' => 'Person already exists emergency creation not needed.',
'message' => 'Person already exists ÔÇô emergency creation not needed.',
]);
}
@@ -1121,10 +1345,10 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
if (! empty($validated['sender_id'])) {
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
if (! $sender) {
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
}
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
}
}
if (! $profile) {
@@ -1167,7 +1391,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
}
// Create an activity before sending
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
$activityData = [
'note' => $activityNote,
'user_id' => optional($request->user())->id,
@@ -1205,7 +1429,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
activityId: $activity?->id,
);
return back()->with('success', 'SMS je bil dodan v čakalno vrsto.');
return back()->with('success', 'SMS je bil dodan v ─Źakalno vrsto.');
} catch (\Throwable $e) {
\Log::warning('SMS enqueue failed', [
'error' => $e->getMessage(),
@@ -1213,7 +1437,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
'phone_id' => $phone_id,
]);
return back()->with('error', 'SMS ni bil dodan v čakalno vrsto.');
return back()->with('error', 'SMS ni bil dodan v ─Źakalno vrsto.');
}
}
@@ -1224,7 +1448,7 @@ public function listContracts(ClientCase $clientCase)
{
$contracts = $clientCase->contracts()
->with('account.type')
->select('id', 'uuid', 'reference', 'active', 'start_date', 'end_date')
->select('id', 'uuid', 'reference', 'active', 'start_date', 'end_date', 'meta')
->latest('id')
->get()
->map(function ($c) {
@@ -1240,6 +1464,7 @@ public function listContracts(ClientCase $clientCase)
'active' => (bool) $c->active,
'start_date' => (string) ($c->start_date ?? ''),
'end_date' => (string) ($c->end_date ?? ''),
'meta' => is_array($c->meta) && ! empty($c->meta) ? $this->flattenMeta($c->meta) : null,
'account' => $acc ? [
'reference' => $acc->reference,
'type' => $acc->type?->name,
@@ -1282,6 +1507,10 @@ public function previewSms(ClientCase $clientCase, Request $request, SmsService
'start_date' => (string) ($contract->start_date ?? ''),
'end_date' => (string) ($contract->end_date ?? ''),
];
// Include contract.meta as flattened key-value pairs
if (is_array($contract->meta) && ! empty($contract->meta)) {
$vars['contract']['meta'] = $this->flattenMeta($contract->meta);
}
if ($contract->account) {
$initialRaw = (string) $contract->account->initial_amount;
$balanceRaw = (string) $contract->account->balance_amount;
@@ -1305,4 +1534,47 @@ public function previewSms(ClientCase $clientCase, Request $request, SmsService
'variables' => $vars,
]);
}
/**
* 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;
}
}
+97 -8
View File
@@ -2,11 +2,15 @@
namespace App\Http\Controllers;
use App\Exports\ClientContractsExport;
use App\Http\Requests\ExportClientContractsRequest;
use App\Models\Client;
use App\Services\ReferenceDataCache;
use DB;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
class ClientController extends Controller
{
@@ -23,7 +27,7 @@ public function index(Client $client, Request $request)
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('clients.id');
})
->where('clients.active', 1)
//->where('clients.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('contracts', function ($join) {
@@ -47,7 +51,7 @@ public function index(Client $client, Request $request)
return Inertia::render('Client/Index', [
'clients' => $query
->paginate($request->integer('per_page', 15))
->paginate($request->integer('per_page', default: 100))
->withQueryString(),
'filters' => $request->only(['search']),
]);
@@ -105,7 +109,8 @@ public function contracts(Client $client, Request $request)
$from = $request->input('from');
$to = $request->input('to');
$search = $request->input('search');
$segmentId = $request->input('segment');
$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'])
@@ -127,16 +132,16 @@ public function contracts(Client $client, Request $request)
->orWhere('person.full_name', 'ilike', '%'.$search.'%');
});
})
->when($segmentId, function ($q) use ($segmentId) {
$q->join('contract_segment', function ($join) use ($segmentId) {
$join->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.segment_id', $segmentId)
->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');
},
@@ -151,15 +156,99 @@ public function contracts(Client $client, Request $request)
'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,
'contracts' => $contractsQuery->paginate($request->integer('per_page', 20))->withQueryString(),
'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)
{
@@ -14,8 +14,8 @@ public function index()
{
return Inertia::render('Settings/ContractConfigs/Index', [
'configs' => ContractConfig::with(['type:id,name', 'segment:id,name'])->get(),
'types' => ContractType::query()->get(['id','name']),
'segments' => Segment::query()->where('active', true)->get(['id','name']),
'types' => ContractType::query()->get(['id', 'name']),
'segments' => Segment::query()->where('active', true)->get(['id', 'name']),
]);
}
@@ -40,8 +40,8 @@ public function store(Request $request)
ContractConfig::create([
'contract_type_id' => $data['contract_type_id'],
'segment_id' => $data['segment_id'],
'is_initial' => (bool)($data['is_initial'] ?? false),
'active' => (bool)($data['active'] ?? true),
'is_initial' => (bool) ($data['is_initial'] ?? false),
'active' => (bool) ($data['active'] ?? true),
]);
return back()->with('success', 'Configuration created');
@@ -57,8 +57,8 @@ public function update(ContractConfig $config, Request $request)
$config->update([
'segment_id' => $data['segment_id'],
'is_initial' => (bool)($data['is_initial'] ?? $config->is_initial),
'active' => (bool)($data['active'] ?? $config->active),
'is_initial' => (bool) ($data['is_initial'] ?? $config->is_initial),
'active' => (bool) ($data['active'] ?? $config->active),
]);
return back()->with('success', 'Configuration updated');
@@ -67,6 +67,7 @@ public function update(ContractConfig $config, Request $request)
public function destroy(ContractConfig $config)
{
$config->delete();
return back()->with('success', 'Configuration deleted');
}
}
+82 -13
View File
@@ -4,26 +4,28 @@
use App\Models\Contract;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
class ContractController extends Controller
{
public function index(Contract $contract) {
public function index(Contract $contract)
{
return Inertia::render('Contract/Index', [
'contracts' => $contract::with(['type', 'debtor'])
->where('active', 1)
->orderByDesc('created_at')
->paginate(10),
'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', [
'contract' => $contract::with(['type', 'client', 'debtor'])->findOrFail($contract->id)
'contract' => $contract::with(['type', 'client', 'debtor'])->findOrFail($contract->id),
]);
}
@@ -33,15 +35,15 @@ public function store(Request $request)
$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([
'reference' => $request->input('reference'),
'start_date' => date('Y-m-d', strtotime($request->input('start_date'))),
'type_id' => $request->input('type_id')
'type_id' => $request->input('type_id'),
]);
});
@@ -50,12 +52,79 @@ public function store(Request $request)
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([
'referenca' => $request->input('referenca'),
'type_id' => $request->input('type_id')
'type_id' => $request->input('type_id'),
]);
return back()->with('success', 'Contract updated')->with('flash_method', 'PUT');
}
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.'));
}
}
+156 -222
View File
@@ -2,15 +2,17 @@
namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\Activity;
use App\Models\Client;
use App\Models\Contract;
use App\Models\Document; // assuming model name Import
// assuming model name Import
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\Schema;
use Inertia\Inertia;
@@ -21,256 +23,188 @@ class DashboardController extends Controller
public function __invoke(SmsService $sms): Response
{
$today = now()->startOfDay();
$yesterday = now()->subDay()->startOfDay();
$staleThreshold = now()->subDays(7); // assumption: stale if no activity in last 7 days
$cacheMinutes = 5;
$clientsTotal = Client::count();
$clientsNew7d = Client::where('created_at', '>=', now()->subDays(7))->count();
// FieldJob table does not have a scheduled_at column (schema shows: assigned_at, completed_at, cancelled_at)
// 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();
// Active clients count - cached
$activeClientsCount = Cache::remember('dashboard:active_clients:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
return Client::where('active', true)->count();
});
// Basic activities deferred list (limit 10)
$activities = Activity::query()
->with(['clientCase:id,uuid'])
->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,
]);
// Active contracts count - cached
$activeContractsCount = Cache::remember('dashboard:active_contracts:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
return Contract::whereNull('deleted_at')->count();
});
// 7-day trends (including today)
$start = now()->subDays(6)->startOfDay();
$end = now()->endOfDay();
// Sum of active contracts' account balance - cached
$totalBalance = Cache::remember('dashboard:total_balance:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
return Account::whereHas('contract', function ($q) {
$q->whereNull('deleted_at');
})->sum('balance_amount') ?? 0;
});
$dateKeys = collect(range(0, 6))
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
$clientTrendRaw = Client::whereBetween('created_at', [$start, $end])
->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');
// Active promises count (not expired or expires today) - cached
$activePromisesCount = Cache::remember('dashboard:active_promises:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
return Account::whereHas('contract', function ($q) {
$q->whereNull('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')
->groupBy('client_cases.id', 'client_cases.uuid', 'client_cases.client_ref', 'client_cases.created_at')
->havingRaw('(MAX(activities.created_at) IS NULL OR MAX(activities.created_at) < ?) AND client_cases.created_at < ?', [$staleThreshold, $staleThreshold])
->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC')
->limit(10)
->get()
->map(function ($c) {
// Reference point: last activity if exists, else creation.
$reference = $c->last_activity_at ? \Illuminate\Support\Carbon::parse($c->last_activity_at) : $c->created_at;
// Use minute precision to avoid jumping to 1 too early (e.g. created just before midnight).
$minutes = $reference ? max(0, $reference->diffInMinutes(now())) : 0;
$daysFraction = $minutes / 1440; // 60 * 24
// Provide both fractional and integer versions (integer preserved for backwards compatibility if needed)
$daysInteger = (int) floor($daysFraction);
->whereNotNull('promise_date')
->whereDate('promise_date', '>=', $today)
->count();
});
return [
'id' => $c->id,
'uuid' => $c->uuid,
'client_ref' => $c->client_ref,
'last_activity_at' => $c->last_activity_at,
'created_at' => $c->created_at,
'days_without_activity' => round($daysFraction, 4), // fractional for finer UI decision (<1 day)
'days_stale' => $daysInteger, // legacy key (integer)
'has_activity' => (bool) $c->last_activity_at,
];
});
// Activities (limit 10) - cached
$activities = Cache::remember('dashboard:activities', $cacheMinutes * 60, function () {
return Activity::query()
->with(['clientCase:id,uuid'])
->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,
]);
});
// Field jobs assigned today
$fieldJobsAssignedToday = FieldJob::query()
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
->with(['contract' => function ($q) {
$q->select('id', 'uuid', 'reference', 'client_case_id')
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
}])
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
->limit(15)
->get()
->map(function ($fj) {
$contract = $fj->contract;
$segmentId = null;
if ($contract && method_exists($contract, 'segments')) {
// Determine active segment via pivot active flag if present
$activeSeg = $contract->segments->first();
if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) {
$segmentId = $activeSeg->id;
// 7-day trends for field jobs - cached
$trends = Cache::remember('dashboard:field_jobs_trends:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
$start = now()->subDays(6)->startOfDay();
$end = now()->endOfDay();
$dateKeys = collect(range(0, 6))
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
->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');
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()
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
->with(['contract' => function ($q) {
$q->select('id', 'uuid', 'reference', 'client_case_id')
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
}])
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
->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;
}
}
}
return [
'id' => $fj->id,
'priority' => $fj->priority,
// Normalize to ISO8601 strings so FE retains timezone & time component
'assigned_at' => $fj->assigned_at?->toIso8601String(),
'created_at' => $fj->created_at?->toIso8601String(),
'contract' => $contract ? [
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'client_case_uuid' => optional($contract->clientCase)->uuid,
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
'segment_id' => $segmentId,
] : null,
];
});
return [
'id' => $fj->id,
'priority' => $fj->priority,
'assigned_at' => $fj->assigned_at?->toIso8601String(),
'created_at' => $fj->created_at?->toIso8601String(),
'contract' => $contract ? [
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'client_case_uuid' => optional($contract->clientCase)->uuid,
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
'segment_id' => $segmentId,
] : null,
];
});
});
// Imports in progress (queued / processing)
$importsInProgress = Import::query()
->whereIn('status', ['queued', 'processing'])
->latest('created_at')
->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)
$activeTemplates = \App\Models\DocumentTemplate::query()
->where('active', true)
->latest('updated_at')
->limit(10)
->get(['id', 'name', 'slug', 'version', 'updated_at']);
// System health (deferred)
$queueBacklog = Schema::hasTable('jobs') ? DB::table('jobs')->count() : null;
$failedJobs = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : null;
// System health for timestamp
$recentActivity = Activity::query()->latest('created_at')->value('created_at');
$lastActivityMinutes = null;
if ($recentActivity) {
// diffInMinutes is absolute (non-negative) but guard anyway & cast to int
$lastActivityMinutes = (int) max(0, now()->diffInMinutes($recentActivity));
}
$systemHealth = [
'queue_backlog' => $queueBacklog,
'failed_jobs' => $failedJobs,
'last_activity_minutes' => $lastActivityMinutes,
'last_activity_iso' => $recentActivity?->toIso8601String(),
'generated_at' => now()->toIso8601String(),
];
return Inertia::render('Dashboard', [
return Inertia::render('Dashboard/Index', [
'kpis' => [
'clients_total' => $clientsTotal,
'clients_new_7d' => $clientsNew7d,
'field_jobs_today' => $fieldJobsToday,
'documents_today' => $documentsToday,
'active_imports' => $activeImports,
'active_contracts' => $activeContracts,
'active_clients' => $activeClientsCount,
'active_contracts' => $activeContractsCount,
'total_balance' => $totalBalance,
'active_promises' => $activePromisesCount,
],
'trends' => $trends,
])->with([ // deferred props (Inertia v2 style)
])->with([
'activities' => fn () => $activities,
'systemHealth' => fn () => $systemHealth,
'staleCases' => fn () => $staleCases,
'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday,
'importsInProgress' => fn () => $importsInProgress,
'activeTemplates' => fn () => $activeTemplates,
'smsStats' => function () use ($sms, $today) {
// Aggregate counts per profile for 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;
'smsStats' => function () use ($sms, $today, $cacheMinutes) {
// 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 = '—';
}
$map['total'] = array_sum($map);
$c = $counts->get($p->id) ?? ['queued' => 0, 'sent' => 0, 'delivered' => 0, 'failed' => 0, 'total' => 0];
return $map;
});
// Important: include credential fields so provider calls have proper credentials
$profiles = SmsProfile::query()
->orderBy('name')
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
// Provider balance may fail; guard and present a placeholder.
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();
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;
use Illuminate\Http\Request;
class DebtController extends Controller
{
//
+94 -41
View File
@@ -25,56 +25,109 @@ public function index(Request $request)
optional($setting)->segment_id,
])->filter()->unique()->values();
$contracts = Contract::query()
->with(['clientCase.person', 'clientCase.client.person', 'type', 'account'])
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) {
$q->whereHas('segments', function ($sq) use ($segmentIds) {
// Relation already filters on active pivots
$sq->whereIn('segments.id', $segmentIds);
});
}, function ($q) {
// No segments configured on FieldJobSetting -> return none
$q->whereRaw('1 = 0');
})
->latest('id')
->limit(50)
->get();
$search = $request->input('search');
$searchAssigned = $request->input('search_assigned');
$assignedUserId = $request->input('assigned_user_id');
$unassignedClientUuids = $request->input('unassigned_client_uuids');
$assignedClientUuids = $request->input('assigned_client_uuids');
// Mirror client onto the contract for simpler frontend access: c.client.person.full_name
$contracts->each(function (Contract $contract): void {
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
$contract->setRelation('client', $contract->clientCase->client);
}
});
$unassignedContracts = Contract::query()
->with(['clientCase.person.addresses', 'clientCase.client.person:id,uuid,full_name', 'type', 'account'])
->when($segmentIds->isNotEmpty(), fn($q) =>
$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
$assignments = collect();
if ($contracts->isNotEmpty()) {
$activeJobs = FieldJob::query()
->whereIn('contract_id', $contracts->pluck('id'))
->whereNull('completed_at')
->whereNull('cancelled_at')
->with(['assignedUser:id,name', 'user:id,name', 'contract:id,uuid'])
->get();
$unassignedClients = $unassignedContracts->get()
->pluck('clientCase.client')
->filter()
->unique('id')
->values();
$assignments = $activeJobs->mapWithKeys(function (FieldJob $job) {
return [
optional($job->contract)->uuid => [
'assigned_to' => $job->assignedUser ? ['id' => $job->assignedUser->id, 'name' => $job->assignedUser->name] : null,
'assigned_by' => $job->user ? ['id' => $job->user->id, 'name' => $job->user->name] : null,
'assigned_at' => $job->assigned_at,
],
];
})->filter();
}
$assignedContracts = Contract::query()
->with(['clientCase.person.addresses', 'clientCase.client.person:id,uuid,full_name', 'type', 'account', 'lastFieldJobs', 'lastFieldJobs.assignedUser', 'lastFieldJobs.user'])
->when($segmentIds->isNotEmpty(), fn($q) =>
$q->whereHas('segments', fn($rq) => $rq->whereIn('segments.id', $segmentIds)),
fn($q) => $q->whereRaw('1 = 0')
)
->when( !empty($searchAssigned), fn ($q) =>
$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']);
return Inertia::render('FieldJob/Index', [
'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,
'assignments' => $assignments,
'filters' => [
'search' => $search,
'search_assigned' => $searchAssigned,
'assigned_user_id' => $assignedUserId,
'unassigned_client_uuids' => $unassignedClientUuids,
'assigned_client_uuids' => $assignedClientUuids,
],
]);
}
+64 -9
View File
@@ -9,6 +9,7 @@
use App\Models\ImportEvent;
use App\Models\ImportTemplate;
use App\Services\CsvImportService;
use App\Services\Import\ImportSimulationServiceV2;
use App\Services\ImportProcessor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -21,14 +22,35 @@ class ImportController extends Controller
// List imports (paginated)
public function index(Request $request)
{
$paginator = Import::query()
$query = Import::query()
->with([
'client:id,uuid,person_id',
'client.person:id,uuid,full_name',
'template:id,name',
])
->orderByDesc('created_at')
->paginate(15);
->orderByDesc('created_at');
// 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 = [
'data' => $paginator->items(),
@@ -164,9 +186,25 @@ public function store(Request $request)
public function process(Import $import, Request $request, ImportProcessor $processor)
{
$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
@@ -405,7 +443,7 @@ public function missingContracts(Import $import)
// 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 = \App\Models\Contract::query()
$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) {
@@ -493,7 +531,7 @@ public function getEvents(Import $import)
public function missingKeyrefRows(Import $import)
{
// Identify row IDs from events. Prefer specific event key, fallback to message pattern
$rowIds = \App\Models\ImportEvent::query()
$rowIds = ImportEvent::query()
->where('import_id', $import->id)
->where(function ($q) {
$q->where('event', 'contract_keyref_not_found')
@@ -673,6 +711,8 @@ public function simulatePayments(Import $import, Request $request)
* 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
* automatically by the simulation service when mappings contain the payment root.
*
* @return \Illuminate\Http\JsonResponse
*/
public function simulate(Import $import, Request $request)
{
@@ -683,7 +723,7 @@ public function simulate(Import $import, Request $request)
$limit = (int) ($validated['limit'] ?? 100);
$verbose = (bool) ($validated['verbose'] ?? false);
$service = app(\App\Services\ImportSimulationService::class);
$service = app(ImportSimulationServiceV2::class);
$result = $service->simulate($import, $limit, $verbose);
return response()->json($result);
@@ -785,6 +825,21 @@ public function destroy(Request $request, Import $import)
$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'],
'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 {
// Ensure fields are arrays for frontend consumption
@@ -23,6 +23,16 @@ public function index()
->orderBy('name')
->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', [
'templates' => $templates->map(fn ($t) => [
'uuid' => $t->uuid,
@@ -35,6 +45,10 @@ public function index()
'name' => $t->client->person?->full_name,
] : null,
]),
'clients' => $clients,
'segments' => $segments,
'decisions' => $decisions,
'actions' => $actions,
]);
}
@@ -111,10 +125,10 @@ public function store(Request $request)
'is_active' => 'boolean',
'reactivate' => 'boolean',
'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.*.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.*.transform' => 'nullable|string|max:50',
'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.decision_id' => 'nullable|integer|exists:decisions,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.history_import' => 'nullable|boolean',
'meta.contract_key_mode' => 'nullable|string|in:reference',
])->validate();
@@ -142,7 +160,28 @@ public function store(Request $request)
$template = null;
DB::transaction(function () use (&$template, $request, $data) {
$paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false);
$historyImport = (bool) (data_get($data, 'meta.history_import') ?? false);
$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) {
$entities = ['contracts', 'accounts', 'payments'];
}
@@ -162,7 +201,11 @@ public function store(Request $request)
'segment_id' => data_get($data, 'meta.segment_id'),
'decision_id' => data_get($data, 'meta.decision_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,
'history_import' => $historyImport ?: null,
'contract_key_mode' => data_get($data, 'meta.contract_key_mode'),
], fn ($v) => ! is_null($v) && $v !== ''),
]);
@@ -244,7 +287,7 @@ public function addMapping(Request $request, ImportTemplate $template)
}
$data = validator($raw, [
'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',
'transform' => 'nullable|string|in:trim,upper,lower',
'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.decision_id' => 'nullable|integer|exists:decisions,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.history_import' => 'nullable|boolean',
'meta.contract_key_mode' => 'nullable|string|in:reference',
])->validate();
@@ -342,6 +389,11 @@ public function update(Request $request, ImportTemplate $template)
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)
@@ -349,6 +401,20 @@ public function update(Request $request, ImportTemplate $template)
if (! empty($finalMeta['payments_import'])) {
$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 = [
'name' => $data['name'],
@@ -381,7 +447,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
}
$data = validator($raw, [
'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
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
'transform' => 'nullable|string|in:trim,upper,lower',
@@ -488,13 +554,14 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
}
$data = validator($raw, [
'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',
'transform' => 'nullable|string|in:trim,upper,lower',
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
'options' => 'nullable|array',
'position' => 'nullable|integer',
])->validate();
$mapping->update([
'source_column' => $data['source_column'],
'entity' => $data['entity'] ?? null,
@@ -505,8 +572,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
'position' => $data['position'] ?? $mapping->position,
]);
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Mapping updated');
return back()->with('success', 'Mapping updated');
}
// Delete a mapping
@@ -583,11 +649,15 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
'segment_id' => $tplMeta['segment_id'] ?? null,
'decision_id' => $tplMeta['decision_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,
], fn ($v) => ! is_null($v) && $v !== ''));
$import->update([
'import_template_id' => $template->id,
'reactivate' => $template->reactivate,
'meta' => $merged,
]);
});
@@ -609,4 +679,138 @@ public function destroy(ImportTemplate $template)
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');
}
}
@@ -19,7 +19,7 @@ public function unread(Request $request)
}
$today = now()->toDateString();
$perPage = max(1, min(100, (int) $request->integer('perPage', 15)));
$perPage = max(1, min(100, (int) $request->integer('per_page', 15)));
$search = trim((string) $request->input('search', ''));
$clientUuid = trim((string) $request->input('client', ''));
$clientId = null;
@@ -35,7 +35,15 @@ public function unread(Request $request)
->select(['id', 'due_date', 'amount', 'contract_id', 'client_case_id', 'created_at'])
->whereNotNull('due_date')
->whereDate('due_date', '<=', $today)
// Removed per-user unread filter: show notifications regardless of individual reads
// 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) {
@@ -108,7 +116,15 @@ public function unread(Request $request)
->select(['contract_id', 'client_case_id'])
->whereNotNull('due_date')
->whereDate('due_date', '<=', $today)
// Removed per-user unread filter for client list base
// 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)
@@ -2,8 +2,6 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class PaymentController extends Controller
{
//
+163 -159
View File
@@ -13,8 +13,12 @@ public function __construct(protected ReferenceDataCache $referenceCache) {}
public function index(Request $request)
{
$userId = $request->user()->id;
$search = $request->input('search');
$clientFilter = $request->input('client');
$perPage = $request->integer('per_page', 15);
$perPage = max(1, min(100, $perPage));
$jobs = FieldJob::query()
$query = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
@@ -23,32 +27,78 @@ public function index(Request $request)
$q->with([
'type:id,name',
'account',
'clientCase.person' => function ($pq) {
$pq->with(['addresses', 'phones']);
},
'clientCase.person.address.type',
'clientCase.person.phones',
'clientCase.client:id,uuid,person_id',
'clientCase.client.person:id,full_name',
]);
},
])
->orderByDesc('assigned_at')
->limit(100)
->get();
->orderByDesc('assigned_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.'%');
});
});
});
}
$jobs = $query->paginate($perPage)->withQueryString();
// Get unique clients for filter dropdown
$clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
$q->where('assigned_user_id', $userId)
->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,
])
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
->values();
return Inertia::render('Phone/Index', [
'jobs' => $jobs,
'clients' => $clients,
'view_mode' => 'assigned',
'filters' => [
'search' => $search,
'client' => $clientFilter,
],
]);
}
public function completedToday(Request $request)
{
$userId = $request->user()->id;
$search = $request->input('search');
$clientFilter = $request->input('client');
$perPage = $request->integer('per_page', 15);
$perPage = max(1, min(100, $perPage));
$start = now()->startOfDay();
$end = now()->endOfDay();
$jobs = FieldJob::query()
$query = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('cancelled_at')
->whereBetween('completed_at', [$start, $end])
@@ -57,190 +107,144 @@ public function completedToday(Request $request)
$q->with([
'type:id,name',
'account',
'clientCase.person' => function ($pq) {
$pq->with(['addresses', 'phones']);
},
'clientCase.person.address.type',
'clientCase.person.phones',
'clientCase.client:id,uuid,person_id',
'clientCase.client.person:id,full_name',
]);
},
])
->orderByDesc('completed_at')
->limit(100)
->get();
->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.'%');
});
});
});
}
$jobs = $query->paginate($perPage)->withQueryString();
// Get unique clients for filter dropdown
$clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
$q->where('assigned_user_id', $userId)
->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', [
'jobs' => $jobs,
'clients' => $clients,
'view_mode' => 'completed-today',
'filters' => [
'search' => $search,
'client' => $clientFilter,
],
]);
}
public function showCase(\App\Models\ClientCase $clientCase, Request $request)
{
$userId = $request->user()->id;
$completedMode = (bool) $request->boolean('completed');
$completedMode = $request->boolean('completed');
// Eager load client case with person details
$case = \App\Models\ClientCase::query()
->with(['person' => fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts'])])
->findOrFail($clientCase->id);
// Eager load case with person details
$case = $clientCase->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts');
// Determine contracts of this case relevant to the current user
// - Normal mode: contracts assigned to me and still active (not completed/cancelled)
// - Completed mode (?completed=1): contracts where my field job was completed today
if ($completedMode) {
$start = now()->startOfDay();
$end = now()->endOfDay();
$contractIds = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('cancelled_at')
->whereBetween('completed_at', [$start, $end])
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->pluck('contract_id')
->unique()
->values();
} else {
$contractIds = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->pluck('contract_id')
->unique()
->values();
}
// Query contracts based on field jobs
$contractsQuery = FieldJob::query()
->where('assigned_user_id', $userId)
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->when($completedMode,
fn ($q) => $q->whereNull('cancelled_at')->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]),
fn ($q) => $q->whereNull('completed_at')->whereNull('cancelled_at')
);
// Get contracts with relationships
$contracts = \App\Models\Contract::query()
->where('client_case_id', $case->id)
->whereIn('id', $contractIds)
->with(['type:id,name', 'account'])
->whereIn('id', $contractsQuery->pluck('contract_id')->unique())
->with(['type:id,name', 'account', 'latestObject'])
->orderByDesc('created_at')
->get();
// Attach latest object (if any) to each contract as last_object for display
if ($contracts->isNotEmpty()) {
$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', $contractIds)
// Build merged documents
$documents = $case->documents()
->orderByDesc('created_at')
->get()
->map(function ($d) use ($contractRefMap) {
$arr = $d->toArray();
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
$arr['documentable_type'] = \App\Models\Contract::class;
$arr['contract_uuid'] = optional(\App\Models\Contract::withTrashed()->find($d->documentable_id))->uuid;
return $arr;
});
$caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) {
$arr = $d->toArray();
$arr['documentable_type'] = \App\Models\ClientCase::class;
$arr['client_case_uuid'] = $case->uuid;
return $arr;
});
$documents = $caseDocs->concat($contractDocs)->sortByDesc('created_at')->values();
// Provide minimal types for PersonInfoGrid
$types = [
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
];
// 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;
});
// Determine segment filters from FieldJobSettings for this case/user context
$settingIds = FieldJob::query()
->where('assigned_user_id', $userId)
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->when(
$completedMode,
function ($q) {
$q->whereNull('cancelled_at')
->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]);
},
function ($q) {
$q->whereNull('completed_at')->whereNull('cancelled_at');
}
->map(fn ($d) => array_merge($d->toArray(), [
'documentable_type' => \App\Models\ClientCase::class,
'client_case_uuid' => $case->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,
]))
)
->pluck('field_job_setting_id')
->filter()
->unique()
->sortByDesc('created_at')
->values();
$segmentIds = collect();
if ($settingIds->isNotEmpty()) {
$segmentIds = \App\Models\FieldJobSetting::query()
->whereIn('id', $settingIds)
->pluck('segment_id')
->filter()
->unique()
->values();
}
// Filter actions and their decisions by the derived segment ids (decisions.segment_id)
$actions = \App\Models\Action::query()
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) {
// Filter actions by their segment_id matching the FieldJobSetting segment(s)
$q->whereIn('segment_id', $segmentIds);
})
->with([
'decisions' => function ($q) {
$q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
},
'decisions.emailTemplate' => function ($q) {
$q->select('id', 'name', 'entity_types', 'allow_attachments');
},
])
->get(['id', 'name', 'color_tag', 'segment_id']);
// Get segment IDs for filtering actions
$segmentIds = \App\Models\FieldJobSetting::query()
->whereIn('id', $contractsQuery->pluck('field_job_setting_id')->filter()->unique())
->pluck('segment_id')
->filter()
->unique();
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,
'contracts' => $contracts,
'documents' => $documents,
'types' => $types,
'account_types' => $this->referenceCache->getAccountTypes(),
// Provide decisions (filtered by segment) with linked email template metadata (entity_types, allow_attachments)
'actions' => $actions,
'activities' => $activities,
'types' => [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
],
'account_types' => \App\Models\AccountType::all(),
'actions' => \App\Models\Action::query()
->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;
use App\Models\Post;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Models\Post;
class PostController extends Controller
{
+73 -29
View File
@@ -2,7 +2,8 @@
namespace App\Http\Controllers;
use App\Reports\ReportRegistry;
use App\Models\Report;
use App\Services\ReportQueryBuilder;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -10,15 +11,19 @@
class ReportController extends Controller
{
public function __construct(protected ReportRegistry $registry) {}
public function __construct(protected ReportQueryBuilder $queryBuilder) {}
public function index(Request $request)
{
$reports = collect($this->registry->all())
$reports = Report::where('enabled', true)
->orderBy('order')
->orderBy('name')
->get()
->map(fn ($r) => [
'slug' => $r->slug(),
'name' => $r->name(),
'description' => $r->description(),
'slug' => $r->slug,
'name' => $r->name,
'description' => $r->description,
'category' => $r->category,
])
->values();
@@ -29,26 +34,30 @@ public function index(Request $request)
public function show(string $slug, Request $request)
{
$report = $this->registry->findBySlug($slug);
abort_if(! $report, 404);
$report->authorize($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
$filters = $this->validateFilters($report->inputs(), $request);
$inputs = $this->buildInputsArray($report);
$filters = $this->validateFilters($inputs, $request);
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
$perPage = (int) ($request->integer('per_page') ?: 25);
$paginator = $report->paginate($filters, $perPage);
$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' => $report->inputs(),
'columns' => $report->columns(),
'slug' => $report->slug,
'name' => $report->name,
'description' => $report->description,
'inputs' => $inputs,
'columns' => $this->buildColumnsArray($report),
'rows' => $rows,
'meta' => [
'total' => $paginator->total(),
@@ -62,14 +71,17 @@ public function show(string $slug, Request $request)
public function data(string $slug, Request $request)
{
$report = $this->registry->findBySlug($slug);
abort_if(! $report, 404);
$report->authorize($request);
$report = Report::with(['filters', 'columns'])
->where('slug', $slug)
->where('enabled', true)
->firstOrFail();
$filters = $this->validateFilters($report->inputs(), $request);
$inputs = $this->buildInputsArray($report);
$filters = $this->validateFilters($inputs, $request);
$perPage = (int) ($request->integer('per_page') ?: 25);
$paginator = $report->paginate($filters, $perPage);
$query = $this->queryBuilder->build($report, $filters);
$paginator = $query->paginate($perPage);
$rows = collect($paginator->items())
->map(fn ($row) => $this->normalizeRow($row))
@@ -85,20 +97,23 @@ public function data(string $slug, Request $request)
public function export(string $slug, Request $request)
{
$report = $this->registry->findBySlug($slug);
abort_if(! $report, 404);
$report->authorize($request);
$report = Report::with(['filters', 'columns'])
->where('slug', $slug)
->where('enabled', true)
->firstOrFail();
$filters = $this->validateFilters($report->inputs(), $request);
$inputs = $this->buildInputsArray($report);
$filters = $this->validateFilters($inputs, $request);
$format = strtolower((string) $request->get('format', 'csv'));
$rows = $report->query($filters)->get()->map(fn ($row) => $this->normalizeRow($row));
$columns = $report->columns();
$filename = $report->slug().'-'.now()->format('Ymd_His');
$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(),
'name' => $report->name,
'columns' => $columns,
'rows' => $rows,
]);
@@ -299,6 +314,35 @@ protected function validateFilters(array $inputs, Request $request): array
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.
*/
+140 -52
View File
@@ -2,12 +2,20 @@
namespace App\Http\Controllers;
use App\Exports\SegmentContractsExport;
use App\Http\Requests\ExportSegmentContractsRequest;
use App\Http\Requests\StoreSegmentRequest;
use App\Http\Requests\UpdateSegmentRequest;
use App\Models\Client;
use App\Models\Contract;
use App\Models\Segment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
class SegmentController extends Controller
{
@@ -44,64 +52,26 @@ 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');
$clientFilter = request('client') ?? request('client_id'); // support either ?client=<uuid|id> or ?client_id=<id>
$contractsQuery = \App\Models\Contract::query()
->whereHas('segments', function ($q) use ($segment) {
$q->where('segments.id', $segment->id)
->where('contract_segment.active', '=', 1);
})
->with([
'clientCase.person',
'clientCase.client.person',
'type',
'account',
])
->latest('id');
$clientFilter = request('client') ?? request('client_id');
$perPage = request()->integer('perPage', request()->integer('per_page', 15));
$perPage = max(1, min(200, $perPage));
// Optional filter by client (accepts numeric id or client uuid)
if (! empty($clientFilter)) {
$contractsQuery->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)) {
$contractsQuery->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.'%');
});
});
}
$contracts = $contractsQuery
->paginate(15)
$contracts = $this->buildContractsQuery($segment, $search, $clientFilter)
->paginate($perPage)
->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);
}
$contracts = $this->hydrateClientShortcut($contracts);
// Build a full client list for this segment (not limited to current page) for the dropdown
$clients = \App\Models\Client::query()
// 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)
->where('contract_segment.active', '=', 1);
@@ -124,6 +94,69 @@ public function show(\App\Models\Segment $segment)
]);
}
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)
{
return Inertia::render('Settings/Segments/Index', [
@@ -155,4 +188,59 @@ public function update(UpdateSegmentRequest $request, Segment $segment)
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');
}
@@ -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,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);
}
}
@@ -72,7 +72,15 @@ public function share(Request $request): array
$activities = \App\Models\Activity::query()
->select(['id', 'due_date', 'amount', 'contract_id', 'client_case_id', 'created_at'])
->whereDate('due_date', $today)
// Removed per-user unread filter: show notifications regardless of individual reads
// 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')
->limit(20)
->get();
@@ -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'),
]);
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ public function rules(): array
'name' => ['required', 'string', 'max:50'],
'description' => ['nullable', 'string', 'max:255'],
'active' => ['boolean'],
'exclude' => ['boolean']
'exclude' => ['boolean'],
];
}
+1 -1
View File
@@ -15,7 +15,7 @@ class PersonCollection extends ResourceCollection
public function toArray(Request $request): array
{
return [
'data' => $this->collection
'data' => $this->collection,
];
}
}
+70 -3
View File
@@ -9,6 +9,7 @@
use App\Models\SmsSender;
use App\Models\SmsTemplate;
use App\Services\Sms\SmsService;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -18,7 +19,7 @@
class PackageItemSmsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $packageItemId)
{
@@ -69,6 +70,10 @@ public function handle(SmsService $sms): void
'start_date' => (string) ($contract->start_date ?? ''),
'end_date' => (string) ($contract->end_date ?? ''),
];
// Include contract.meta as flattened key-value pairs for template access
if (is_array($contract->meta) && ! empty($contract->meta)) {
$variables['contract']['meta'] = $this->flattenMeta($contract->meta);
}
if ($contract->account) {
// Preserve raw values and provide EU-formatted versions for SMS rendering
$initialRaw = (string) $contract->account->initial_amount;
@@ -97,7 +102,7 @@ public function handle(SmsService $sms): void
/** @var SmsSender|null $sender */
$sender = $senderId ? SmsSender::find($senderId) : null;
/** @var SmsTemplate|null $template */
$template = $templateId ? SmsTemplate::find($templateId) : null;
$template = $templateId ? SmsTemplate::with(['action', 'decision'])->find($templateId) : null;
$to = $target['number'] ?? null;
if (! is_string($to) || $to === '') {
@@ -117,7 +122,7 @@ public function handle(SmsService $sms): void
$key = $scope === 'per_profile' && $profile ? "sms:{$provider}:{$profile->id}" : "sms:{$provider}";
// Throttle
$sendClosure = function () use ($sms, $item, $package, $profile, $sender, $template, $to, $variables, $deliveryReport, $bodyOverride) {
$sendClosure = function () use ($sms, $item, $package, $profile, $sender, $template, $to, $variables, $deliveryReport, $bodyOverride, $target) {
// Idempotency key (optional external use)
if (empty($item->idempotency_key)) {
$hash = sha1(implode('|', [
@@ -188,6 +193,25 @@ public function handle(SmsService $sms): void
$item->last_error = $log->status === 'sent' ? null : ($log->meta['error_message'] ?? 'Failed');
$item->save();
// Create activity if template has action_id and decision_id configured and SMS was sent successfully
if ($newStatus === 'sent' && $template && ($template->action_id || $template->decision_id)) {
if (! empty($target['contract_id'])) {
$contract = Contract::query()->with('clientCase')->find($target['contract_id']);
if ($contract && $contract->client_case_id) {
\App\Models\Activity::create(array_filter([
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
'action_id' => $template->action_id,
'decision_id' => $template->decision_id,
'note' => "SMS poslan na {$to}: {$result['message']}",
'created_at' => now(),
'updated_at' => now(),
]));
}
}
}
// Update package counters atomically
if ($newStatus === 'sent') {
$package->increment('sent_count');
@@ -214,4 +238,47 @@ public function handle(SmsService $sms): void
$sendClosure();
}
}
/**
* 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;
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Jobs;
use App\Models\Import;
use App\Models\ImportEvent;
use App\Services\Import\ImportServiceV2;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessLargeImportJob implements ShouldQueue
{
use InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 3600; // 1 hour
public $tries = 3;
/**
* Create a new job instance.
*/
public function __construct(
public Import $import,
public ?int $userId = null
) {
//
}
/**
* Execute the job.
*/
public function handle(): void
{
Log::info('ProcessLargeImportJob started', [
'import_id' => $this->import->id,
'user_id' => $this->userId,
]);
try {
$user = $this->userId ? \App\Models\User::find($this->userId) : null;
$service = app(ImportServiceV2::class);
$results = $service->process($this->import, $user);
Log::info('ProcessLargeImportJob completed', [
'import_id' => $this->import->id,
'results' => $results,
]);
ImportEvent::create([
'import_id' => $this->import->id,
'user_id' => $this->userId,
'event' => 'queue_job_completed',
'level' => 'info',
'message' => sprintf(
'Queued import completed: %d imported, %d skipped, %d invalid',
$results['imported'],
$results['skipped'],
$results['invalid']
),
'context' => $results,
]);
} catch (\Throwable $e) {
Log::error('ProcessLargeImportJob failed', [
'import_id' => $this->import->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$this->import->update(['status' => 'failed']);
ImportEvent::create([
'import_id' => $this->import->id,
'user_id' => $this->userId,
'event' => 'queue_job_failed',
'level' => 'error',
'message' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
Log::error('ProcessLargeImportJob permanently failed', [
'import_id' => $this->import->id,
'error' => $exception->getMessage(),
]);
$this->import->update(['status' => 'failed']);
ImportEvent::create([
'import_id' => $this->import->id,
'user_id' => $this->userId,
'event' => 'queue_job_permanently_failed',
'level' => 'error',
'message' => 'Import job failed after maximum retries: '.$exception->getMessage(),
]);
}
}
+3 -3
View File
@@ -109,7 +109,7 @@ public function handle(SmsService $sms): void
}
// If no pre-created activity is provided and invoked from the case UI with a selected template, create an Activity
if (!$this->activityId && $this->templateId && $this->clientCaseId && $log) {
if (! $this->activityId && $this->templateId && $this->clientCaseId && $log) {
try {
/** @var SmsTemplate|null $template */
$template = SmsTemplate::find($this->templateId);
@@ -118,10 +118,10 @@ public function handle(SmsService $sms): void
if ($template && $case) {
$note = '';
if ($log->status === 'sent') {
$note = sprintf('Št: %s | Telo: %s', (string) $this->to, (string) $this->content);
$note = sprintf('Tel: %s | Telo: %s', (string) $this->to, (string) $this->content);
} elseif ($log->status === 'failed') {
$note = sprintf(
'Št: %s | Telo: %s | Napaka: %s',
'Tel: %s | Telo: %s | Napaka: %s',
(string) $this->to,
(string) $this->content,
'SMS ni bil poslan!'
+7 -2
View File
@@ -75,7 +75,8 @@ protected function performSmtpAuthTest(MailProfile $profile): void
}
$remote = ($encryption === 'ssl') ? 'ssl://'.$host : $host;
$errno = 0; $errstr = '';
$errno = 0;
$errstr = '';
$socket = @fsockopen($remote, $port, $errno, $errstr, 15);
if (! $socket) {
throw new \RuntimeException("Connect failed: $errstr ($errno)");
@@ -104,7 +105,9 @@ protected function performSmtpAuthTest(MailProfile $profile): void
// Cleanly quit
$this->command($socket, "QUIT\r\n", [221], 'QUIT');
} finally {
try { fclose($socket); } catch (\Throwable) {
try {
fclose($socket);
} catch (\Throwable) {
// ignore
}
}
@@ -116,6 +119,7 @@ protected function performSmtpAuthTest(MailProfile $profile): void
protected function command($socket, string $cmd, array $expect, string $context): string
{
fwrite($socket, $cmd);
return $this->expect($socket, $expect, $context);
}
@@ -138,6 +142,7 @@ protected function expect($socket, array $expectedCodes, string $context): strin
if (! in_array($code, $expectedCodes, true)) {
throw new \RuntimeException("Unexpected SMTP code $code during $context: ".implode(' | ', $lines));
}
return $line;
}
}
+2
View File
@@ -6,10 +6,12 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Account extends Model
{
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
use SoftDeletes;
use HasFactory;
protected $fillable = [
+1 -1
View File
@@ -13,6 +13,7 @@ class Action extends Model
{
/** @use HasFactory<\Database\Factories\ActionFactory> */
use HasFactory;
use Searchable;
protected $fillable = ['name', 'color_tag', 'segment_id'];
@@ -31,5 +32,4 @@ public function activities(): HasMany
{
return $this->hasMany(\App\Models\Activity::class);
}
}
+5 -6
View File
@@ -3,22 +3,23 @@
namespace App\Models;
use App\Traits\Uuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Scout\Searchable;
class Client extends Model
{
/** @use HasFactory<\Database\Factories\ClientFactory> */
use HasFactory;
use Uuid;
use Searchable;
use Uuid;
protected $fillable = [
'person_id'
'person_id',
];
protected $hidden = [
@@ -26,7 +27,6 @@ class Client extends Model
'person_id',
];
protected function makeAllSearchableUsing(Builder $query): Builder
{
return $query->with('person');
@@ -37,11 +37,10 @@ public function toSearchableArray(): array
return [
'person.full_name' => '',
'person_addresses.address' => ''
'person_addresses.address' => '',
];
}
public function person(): BelongsTo
{
return $this->belongsTo(\App\Models\Person\Person::class);
+22
View File
@@ -29,6 +29,7 @@ class Contract extends Model
'end_date',
'client_case_id',
'type_id',
'active',
'description',
'meta',
];
@@ -112,6 +113,11 @@ public function segments(): BelongsToMany
->wherePivot('active', true);
}
public function attachedSegments(): BelongsToMany
{
return $this->belongsToMany(\App\Models\Segment::class);
}
public function account(): HasOne
{
// Use latestOfMany to always surface newest account snapshot if multiple exist.
@@ -130,6 +136,22 @@ public function documents(): MorphMany
return $this->morphMany(\App\Models\Document::class, 'documentable');
}
public function fieldJobs(): HasMany
{
return $this->hasMany(\App\Models\FieldJob::class);
}
public function lastFieldJobs(): HasOne {
return $this->hasOne(\App\Models\FieldJob::class)->latestOfMany();
}
public function latestObject(): HasOne
{
return $this->hasOne(\App\Models\CaseObject::class)
->whereNull('deleted_at')
->latest();
}
protected static function booted(): void
{
static::created(function (Contract $contract): void {
+6 -1
View File
@@ -24,6 +24,8 @@ class FieldJob extends Model
'priority',
'notes',
'address_snapshot ',
'last_activity',
'added_activity'
];
protected $casts = [
@@ -31,6 +33,8 @@ class FieldJob extends Model
'completed_at' => 'datetime',
'cancelled_at' => 'datetime',
'priority' => 'boolean',
'last_activity' => 'datetime',
'added_activity' => 'boolean',
'address_snapshot ' => 'array',
];
@@ -90,7 +94,8 @@ public function user(): BelongsTo
public function contract(): BelongsTo
{
return $this->belongsTo(Contract::class, 'contract_id');
return $this->belongsTo(Contract::class, 'contract_id')
->where('active', true);
}
/**
+9
View File
@@ -17,6 +17,11 @@ class ImportEntity extends Model
'meta',
'rules',
'ui',
'handler_class',
'validation_rules',
'processing_options',
'is_active',
'priority',
];
protected $casts = [
@@ -27,5 +32,9 @@ class ImportEntity extends Model
'meta' => 'boolean',
'rules' => 'array',
'ui' => 'array',
'validation_rules' => 'array',
'processing_options' => 'array',
'is_active' => 'boolean',
'priority' => 'integer',
];
}
+1 -1
View File
@@ -11,7 +11,7 @@ class ImportEvent extends Model
use HasFactory;
protected $fillable = [
'import_id','user_id','event','level','message','context','import_row_id'
'import_id', 'user_id', 'event', 'level', 'message', 'context', 'import_row_id',
];
protected $casts = [
+5
View File
@@ -22,6 +22,11 @@ class ImportTemplate extends Model
'reactivate' => 'boolean',
];
public function getRouteKeyName(): string
{
return 'uuid';
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
+1 -1
View File
@@ -11,7 +11,7 @@ class ImportTemplateMapping extends Model
use HasFactory;
protected $fillable = [
'import_template_id', 'entity', 'source_column', 'target_field', 'transform', 'apply_mode', 'options', 'position'
'import_template_id', 'entity', 'source_column', 'target_field', 'transform', 'apply_mode', 'options', 'position',
];
protected $casts = [
+65 -4
View File
@@ -12,6 +12,7 @@
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
use Laravel\Scout\Attributes\SearchUsingFullText;
use Laravel\Scout\Searchable;
class Person extends Model
@@ -45,6 +46,7 @@ class Person extends Model
'group_id',
'type_id',
'user_id',
'employer'
];
protected $hidden = [
@@ -64,6 +66,14 @@ protected static function booted()
$person->nu = static::generateUniqueNu();
}
});
static::saving(function (Person $person) {
$person->full_name_search = static::buildFullNameSearchPayload(
$person->first_name,
$person->last_name,
$person->full_name
);
});
}
protected function makeAllSearchableUsing(Builder $query): Builder
@@ -71,16 +81,20 @@ protected function makeAllSearchableUsing(Builder $query): Builder
return $query->with(['addresses', 'phones', 'emails']);
}
#[SearchUsingFullText(['full_name_search'], ['config' => 'simple'])]
public function toSearchableArray(): array
{
return [
'first_name' => '',
'last_name' => '',
'full_name' => '',
$columns = [
'first_name' => (string) $this->first_name,
'last_name' => (string) $this->last_name,
'full_name' => (string) $this->full_name,
'person_addresses.address' => '',
'person_phones.nu' => '',
'emails.value' => '',
'full_name_search' => (string) $this->full_name_search,
];
return $columns;
}
public function phones(): HasMany
@@ -99,6 +113,14 @@ public function addresses(): HasMany
->orderBy('id');
}
public function address(): HasOne
{
return $this->hasOne(\App\Models\Person\PersonAddress::class)
->with(['type'])
->where('active', '=', 1)
->oldestOfMany('id');
}
public function emails(): HasMany
{
return $this->hasMany(\App\Models\Email::class, 'person_id')
@@ -144,4 +166,43 @@ protected static function generateUniqueNu(): string
return $nu;
}
protected static function buildFullNameSearchPayload(?string $firstName, ?string $lastName, ?string $fullName): string
{
$segments = collect([
static::joinNameParts($firstName, $lastName),
static::joinNameParts($lastName, $firstName),
$fullName,
])->filter();
if ($segments->isEmpty()) {
return '';
}
return $segments
->map(fn (string $segment): string => static::normalizeSegment($segment))
->filter()
->unique()
->implode(' ');
}
protected static function joinNameParts(?string $first, ?string $second): ?string
{
$parts = collect([$first, $second])->filter(fn ($value) => filled($value));
if ($parts->isEmpty()) {
return null;
}
return $parts->implode(' ');
}
protected static function normalizeSegment(?string $value): ?string
{
if (empty($value)) {
return null;
}
return (string) Str::of($value)->squish()->lower();
}
}
-1
View File
@@ -15,5 +15,4 @@ public function persons(): HasMany
{
return $this->hasMany(\App\Models\Person\Person::class);
}
}
+1 -3
View File
@@ -4,7 +4,6 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PersonType extends Model
@@ -14,12 +13,11 @@ class PersonType extends Model
protected $fillable = [
'name',
'description'
'description',
];
public function persons(): HasMany
{
return $this->hasMany(\App\Models\Person\Person::class);
}
}
+1
View File
@@ -13,6 +13,7 @@ class Post extends Model
public function toSearchableArray()
{
$array = $this->toArray();
return $array;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Report extends Model
{
protected $fillable = [
'slug',
'name',
'description',
'category',
'enabled',
'order',
];
protected $casts = [
'enabled' => 'boolean',
'order' => 'integer',
];
public function entities(): HasMany
{
return $this->hasMany(ReportEntity::class)->orderBy('order');
}
public function columns(): HasMany
{
return $this->hasMany(ReportColumn::class)->orderBy('order');
}
public function filters(): HasMany
{
return $this->hasMany(ReportFilter::class)->orderBy('order');
}
public function conditions(): HasMany
{
return $this->hasMany(ReportCondition::class)->orderBy('order');
}
public function orders(): HasMany
{
return $this->hasMany(ReportOrder::class)->orderBy('order');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportColumn extends Model
{
protected $fillable = [
'report_id',
'key',
'label',
'type',
'expression',
'sortable',
'visible',
'order',
'format_options',
];
protected $casts = [
'sortable' => 'boolean',
'visible' => 'boolean',
'order' => 'integer',
'format_options' => 'array',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportCondition extends Model
{
protected $fillable = [
'report_id',
'column',
'operator',
'value_type',
'value',
'filter_key',
'logical_operator',
'group_id',
'order',
'enabled',
];
protected $casts = [
'enabled' => 'boolean',
'order' => 'integer',
'group_id' => 'integer',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportEntity extends Model
{
protected $fillable = [
'report_id',
'model_class',
'alias',
'join_type',
'join_first',
'join_operator',
'join_second',
'order',
];
protected $casts = [
'order' => 'integer',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportFilter extends Model
{
protected $fillable = [
'report_id',
'key',
'label',
'type',
'nullable',
'default_value',
'options',
'data_source',
'order',
];
protected $casts = [
'nullable' => 'boolean',
'order' => 'integer',
'options' => 'array',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportOrder extends Model
{
protected $fillable = [
'report_id',
'column',
'direction',
'order',
];
protected $casts = [
'order' => 'integer',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}
+6 -4
View File
@@ -15,22 +15,24 @@ class Segment extends Model
'name',
'description',
'active',
'exclude'
'exclude',
];
protected function casts(): array
{
return [
'active' => 'boolean',
'exclude' => 'boolean'
'exclude' => 'boolean',
];
}
public function contracts(): BelongsToMany {
public function contracts(): BelongsToMany
{
return $this->belongsToMany(\App\Models\Contract::class);
}
public function clientCase(): BelongsToMany {
public function clientCase(): BelongsToMany
{
return $this->belongsToMany(\App\Models\ClientCase::class)->withTimestamps();
}
}
+2
View File
@@ -30,6 +30,7 @@ class User extends Authenticatable
'name',
'email',
'password',
'active',
];
/**
@@ -63,6 +64,7 @@ protected function casts(): array
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'active' => 'boolean',
];
}
+1
View File
@@ -12,6 +12,7 @@ protected function isAdmin(User $user): bool
if (app()->environment('testing')) {
return true; // simplify for tests
}
return method_exists($user, 'isAdmin') ? $user->isAdmin() : $user->id === 1; // fallback heuristic
}
-1
View File
@@ -4,7 +4,6 @@
use App\Models\Post;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class PostPolicy
{
+19
View File
@@ -6,11 +6,14 @@
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
@@ -33,6 +36,22 @@ public function boot(): void
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::authenticateUsing(function (Request $request) {
$user = User::where('email', $request->email)->first();
if ($user && Hash::check($request->password, $user->password)) {
if (! $user->active) {
throw ValidationException::withMessages([
Fortify::username() => ['Uporabnik je onemogočen.'],
]);
}
return $user;
}
return null;
});
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
-36
View File
@@ -1,36 +0,0 @@
<?php
namespace App\Providers;
use App\Reports\ActionsDecisionsCountReport;
use App\Reports\ActivitiesPerPeriodReport;
use App\Reports\ActiveContractsReport;
use App\Reports\FieldJobsCompletedReport;
use App\Reports\DecisionsCountReport;
use App\Reports\ReportRegistry;
use App\Reports\SegmentActivityCountsReport;
use Illuminate\Support\ServiceProvider;
class ReportServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ReportRegistry::class, function () {
$registry = new ReportRegistry;
// Register built-in reports here
$registry->register(new FieldJobsCompletedReport);
$registry->register(new SegmentActivityCountsReport);
$registry->register(new ActionsDecisionsCountReport);
$registry->register(new ActivitiesPerPeriodReport);
$registry->register(new DecisionsCountReport);
$registry->register(new ActiveContractsReport);
return $registry;
});
}
public function boot(): void
{
//
}
}
@@ -1,53 +0,0 @@
<?php
namespace App\Reports;
use App\Models\Activity;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
class ActionsDecisionsCountReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'actions-decisions-counts';
}
public function name(): string
{
return 'Dejanja / Odločitve štetje';
}
public function description(): ?string
{
return 'Število aktivnosti po dejanjih in odločitvah v obdobju.';
}
public function inputs(): array
{
return [
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
];
}
public function columns(): array
{
return [
['key' => 'action_name', 'label' => 'Dejanje'],
['key' => 'decision_name', 'label' => 'Odločitev'],
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
];
}
public function query(array $filters): Builder
{
return Activity::query()
->leftJoin('actions', 'activities.action_id', '=', 'actions.id')
->leftJoin('decisions', 'activities.decision_id', '=', 'decisions.id')
->when(! empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
->when(! empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
->groupBy('actions.name', 'decisions.name')
->selectRaw("COALESCE(actions.name, '—') as action_name, COALESCE(decisions.name, '—') as decision_name, COUNT(*) as activities_count");
}
}
-78
View File
@@ -1,78 +0,0 @@
<?php
namespace App\Reports;
use App\Models\Contract;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
class ActiveContractsReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'active-contracts';
}
public function name(): string
{
return 'Aktivne pogodbe';
}
public function description(): ?string
{
return 'Pogodbe, ki so aktivne na izbrani dan, z možnostjo filtriranja po stranki.';
}
public function inputs(): array
{
return [
['key' => 'client_uuid', 'type' => 'select:client', 'label' => 'Stranka', 'nullable' => true],
];
}
public function columns(): array
{
return [
['key' => 'contract_reference', 'label' => 'Pogodba'],
['key' => 'client_name', 'label' => 'Stranka'],
['key' => 'person_name', 'label' => 'Zadeva (oseba)'],
['key' => 'start_date', 'label' => 'Začetek'],
['key' => 'end_date', 'label' => 'Konec'],
['key' => 'balance_amount', 'label' => 'Saldo'],
];
}
public function query(array $filters): Builder
{
$asOf = now()->toDateString();
return Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->leftJoin('clients', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('person as client_people', 'clients.person_id', '=', 'client_people.id')
->leftJoin('person as subject_people', 'client_cases.person_id', '=', 'subject_people.id')
->leftJoin('accounts', 'contracts.id', '=', 'accounts.contract_id')
->when(! empty($filters['client_uuid']), fn ($q) => $q->where('clients.uuid', $filters['client_uuid']))
// Active as of date: start_date <= as_of (or null) AND (end_date is null OR end_date >= as_of)
->where(function ($q) use ($asOf) {
$q->whereNull('contracts.start_date')
->orWhereDate('contracts.start_date', '<=', $asOf);
})
->where(function ($q) use ($asOf) {
$q->whereNull('contracts.end_date')
->orWhereDate('contracts.end_date', '>=', $asOf);
})
->select([
'contracts.id',
'contracts.start_date',
'contracts.end_date',
])
->addSelect([
\DB::raw('contracts.reference as contract_reference'),
\DB::raw('client_people.full_name as client_name'),
\DB::raw('subject_people.full_name as person_name'),
\DB::raw('CAST(accounts.balance_amount AS FLOAT) as balance_amount'),
])
->orderBy('contracts.start_date', 'asc');
}
}
-95
View File
@@ -1,95 +0,0 @@
<?php
namespace App\Reports;
use App\Models\Activity;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class ActivitiesPerPeriodReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'activities-per-period';
}
public function name(): string
{
return 'Aktivnosti po obdobjih';
}
public function description(): ?string
{
return 'Seštevek aktivnosti po dneh/tednih/mesecih v obdobju.';
}
public function inputs(): array
{
return [
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
['key' => 'period', 'type' => 'string', 'label' => 'Obdobje (day|week|month)', 'default' => 'day'],
];
}
public function columns(): array
{
return [
['key' => 'period', 'label' => 'Obdobje'],
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
];
}
public function query(array $filters): Builder
{
$periodRaw = $filters['period'] ?? 'day';
$period = in_array($periodRaw, ['day', 'week', 'month'], true) ? $periodRaw : 'day';
$driver = DB::getDriverName();
// Build database-compatible period expressions
if ($driver === 'sqlite') {
if ($period === 'day') {
// Use string slice to avoid timezone conversion differences in SQLite
$selectExpr = DB::raw('SUBSTR(activities.created_at, 1, 10) as period');
$groupExpr = DB::raw('SUBSTR(activities.created_at, 1, 10)');
$orderExpr = DB::raw('SUBSTR(activities.created_at, 1, 10)');
} elseif ($period === 'month') {
$selectExpr = DB::raw("strftime('%Y-%m-01', activities.created_at) as period");
$groupExpr = DB::raw("strftime('%Y-%m-01', activities.created_at)");
$orderExpr = DB::raw("strftime('%Y-%m-01', activities.created_at)");
} else { // week
$selectExpr = DB::raw("strftime('%Y-%W', activities.created_at) as period");
$groupExpr = DB::raw("strftime('%Y-%W', activities.created_at)");
$orderExpr = DB::raw("strftime('%Y-%W', activities.created_at)");
}
} elseif ($driver === 'mysql') {
if ($period === 'day') {
$selectExpr = DB::raw('DATE(activities.created_at) as period');
$groupExpr = DB::raw('DATE(activities.created_at)');
$orderExpr = DB::raw('DATE(activities.created_at)');
} elseif ($period === 'month') {
$selectExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01') as period");
$groupExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01')");
$orderExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01')");
} else { // week
// ISO week-year-week number for grouping; adequate for summary grouping
$selectExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v') as period");
$groupExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v')");
$orderExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v')");
}
} else { // postgres and others supporting date_trunc
$selectExpr = DB::raw("date_trunc('".$period."', activities.created_at) as period");
$groupExpr = DB::raw("date_trunc('".$period."', activities.created_at)");
$orderExpr = DB::raw("date_trunc('".$period."', activities.created_at)");
}
return Activity::query()
->when(! empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
->when(! empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
->groupBy($groupExpr)
->orderBy($orderExpr)
->select($selectExpr)
->selectRaw('COUNT(*) as activities_count');
}
}
-33
View File
@@ -1,33 +0,0 @@
<?php
namespace App\Reports;
use App\Reports\Contracts\Report;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Request;
abstract class BaseEloquentReport implements Report
{
public function description(): ?string
{
return null;
}
public function authorize(Request $request): void
{
// Default: no extra checks. Controllers can gate via middleware.
}
/**
* @param array<string, mixed> $filters
*/
public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator
{
/** @var EloquentBuilder|QueryBuilder $query */
$query = $this->query($filters);
return $query->paginate($perPage);
}
}
-54
View File
@@ -1,54 +0,0 @@
<?php
namespace App\Reports\Contracts;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Request;
interface Report
{
public function slug(): string;
public function name(): string;
public function description(): ?string;
/**
* Return an array describing input filters (type, label, default, options) for UI.
* Example item: ['key' => 'from', 'type' => 'date', 'label' => 'Od', 'default' => today()]
*
* @return array<int, array<string, mixed>>
*/
public function inputs(): array;
/**
* Return column definitions for the table and exports.
* Example: [ ['key' => 'id', 'label' => '#'], ['key' => 'user', 'label' => 'Uporabnik'] ]
*
* @return array<int, array<string, mixed>>
*/
public function columns(): array;
/**
* Build the data source query for the report based on validated filters.
* Should return an Eloquent or Query builder.
*
* @param array<string, mixed> $filters
* @return EloquentBuilder|QueryBuilder
*/
public function query(array $filters);
/**
* Optional per-report authorization logic.
*/
public function authorize(Request $request): void;
/**
* Execute the report and return a paginator for UI.
*
* @param array<string, mixed> $filters
*/
public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator;
}
-51
View File
@@ -1,51 +0,0 @@
<?php
namespace App\Reports;
use App\Models\Activity;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
class DecisionsCountReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'decisions-counts';
}
public function name(): string
{
return 'Odločitve štetje';
}
public function description(): ?string
{
return 'Število aktivnosti po odločitvah v izbranem obdobju.';
}
public function inputs(): array
{
return [
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
];
}
public function columns(): array
{
return [
['key' => 'decision_name', 'label' => 'Odločitev'],
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
];
}
public function query(array $filters): Builder
{
return Activity::query()
->leftJoin('decisions', 'activities.decision_id', '=', 'decisions.id')
->when(!empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
->when(!empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
->groupBy('decisions.name')
->selectRaw("COALESCE(decisions.name, '—') as decision_name, COUNT(*) as activities_count");
}
}
-60
View File
@@ -1,60 +0,0 @@
<?php
namespace App\Reports;
use App\Models\FieldJob;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
class FieldJobsCompletedReport extends BaseEloquentReport
{
public function slug(): string
{
return 'field-jobs-completed';
}
public function name(): string
{
return 'Zaključeni tereni';
}
public function description(): ?string
{
return 'Pregled zaključenih terenov po datumu in uporabniku.';
}
public function inputs(): array
{
return [
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'default' => now()->startOfMonth()->toDateString()],
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'default' => now()->toDateString()],
['key' => 'user_id', 'type' => 'select:user', 'label' => 'Uporabnik', 'default' => null],
];
}
public function columns(): array
{
return [
['key' => 'id', 'label' => '#'],
['key' => 'contract_reference', 'label' => 'Pogodba'],
['key' => 'assigned_user_name', 'label' => 'Terenski'],
['key' => 'completed_at', 'label' => 'Zaključeno'],
['key' => 'notes', 'label' => 'Opombe'],
];
}
/**
* @param array<string, mixed> $filters
*/
public function query(array $filters): EloquentBuilder
{
$from = isset($filters['from']) ? now()->parse($filters['from'])->startOfDay() : now()->startOfMonth();
$to = isset($filters['to']) ? now()->parse($filters['to'])->endOfDay() : now()->endOfDay();
return FieldJob::query()
->whereNull('cancelled_at')
->whereBetween('completed_at', [$from, $to])
->when(! empty($filters['user_id']), fn ($q) => $q->where('assigned_user_id', $filters['user_id']))
->with(['assignedUser:id,name', 'contract:id,reference'])
->select(['id', 'assigned_user_id', 'contract_id', 'completed_at', 'notes']);
}
}
-29
View File
@@ -1,29 +0,0 @@
<?php
namespace App\Reports;
use App\Reports\Contracts\Report;
class ReportRegistry
{
/** @var array<string, Report> */
protected array $reports = [];
public function register(Report $report): void
{
$this->reports[$report->slug()] = $report;
}
/**
* @return array<string, Report>
*/
public function all(): array
{
return $this->reports;
}
public function findBySlug(string $slug): ?Report
{
return $this->reports[$slug] ?? null;
}
}
@@ -1,54 +0,0 @@
<?php
namespace App\Reports;
use App\Models\Activity;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
class SegmentActivityCountsReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'segment-activity-counts';
}
public function name(): string
{
return 'Aktivnosti po segmentih';
}
public function description(): ?string
{
return 'Število aktivnosti po segmentih v izbranem obdobju (glede na segment dejanja).';
}
public function inputs(): array
{
return [
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
];
}
public function columns(): array
{
return [
['key' => 'segment_name', 'label' => 'Segment'],
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
];
}
public function query(array $filters): Builder
{
$q = Activity::query()
->join('actions', 'activities.action_id', '=', 'actions.id')
->leftJoin('segments', 'actions.segment_id', '=', 'segments.id')
->when(! empty($filters['from']), fn ($qq) => $qq->whereDate('activities.created_at', '>=', $filters['from']))
->when(! empty($filters['to']), fn ($qq) => $qq->whereDate('activities.created_at', '<=', $filters['to']))
->groupBy('segments.name')
->selectRaw("COALESCE(segments.name, 'Brez segmenta') as segment_name, COUNT(*) as activities_count");
return $q;
}
}
+3 -1
View File
@@ -69,6 +69,8 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null,
$entities = $flat;
}
// dd($entities);
foreach ($entities as $entityDef) {
$rawTable = $entityDef['table'] ?? null;
if (! $rawTable) {
@@ -97,7 +99,7 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null,
// Process in batches to avoid locking large tables
while (true) {
$query = DB::table($table)->whereNull('deleted_at');
if (Schema::hasColumn($table, 'active')) {
if (Schema::hasColumn($table, 'active') && ! $reactivate) {
$query->where('active', 1);
}
// Apply context filters or chain derived filters
+6 -50
View File
@@ -2,55 +2,11 @@
namespace App\Services;
class DateNormalizer
/**
* Backward compatibility alias for DateNormalizer.
* Old code references App\Services\DateNormalizer, but actual class is at App\Services\Import\DateNormalizer.
*/
class DateNormalizer extends \App\Services\Import\DateNormalizer
{
/**
* Normalize a raw date string to Y-m-d (ISO) or return null if unparseable.
* Accepted examples: 30.10.2025, 30/10/2025, 30-10-2025, 1/2/25, 2025-10-30
*/
public static function toDate(?string $raw): ?string
{
if ($raw === null) {
return null;
}
$raw = trim($raw);
if ($raw === '') {
return null;
}
// Common European and ISO formats first (day-first, then ISO)
$candidates = [
'd.m.Y', 'd.m.y',
'd/m/Y', 'd/m/y',
'd-m-Y', 'd-m-y',
'Y-m-d', 'Y/m/d', 'Y.m.d',
];
foreach ($candidates as $fmt) {
$dt = \DateTime::createFromFormat($fmt, $raw);
if ($dt instanceof \DateTime) {
$errors = \DateTime::getLastErrors();
if ((int) ($errors['warning_count'] ?? 0) === 0 && (int) ($errors['error_count'] ?? 0) === 0) {
// Adjust two-digit years to reasonable century (00-69 => 2000-2069, 70-99 => 1970-1999)
$year = (int) $dt->format('Y');
if ($year < 100) {
$year += ($year <= 69) ? 2000 : 1900;
// Rebuild date with corrected year
$month = (int) $dt->format('m');
$day = (int) $dt->format('d');
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
return $dt->format('Y-m-d');
}
}
}
// Fallback: strtotime (permissive). If fails, return null.
$ts = @strtotime($raw);
if ($ts === false) {
return null;
}
return date('Y-m-d', $ts);
}
// This class extends the actual DateNormalizer for backward compatibility
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Services\Import;
use App\Models\ImportEntity;
use App\Services\Import\Contracts\EntityHandlerInterface;
use Illuminate\Support\Facades\Validator;
abstract class BaseEntityHandler implements EntityHandlerInterface
{
protected ?ImportEntity $entityConfig;
public function __construct(?ImportEntity $entityConfig = null)
{
$this->entityConfig = $entityConfig;
}
/**
* Validate mapped data using configuration rules.
*/
public function validate(array $mapped): array
{
$rules = $this->entityConfig?->validation_rules ?? [];
if (empty($rules)) {
return ['valid' => true, 'errors' => []];
}
$validator = Validator::make($mapped, $rules);
if ($validator->fails()) {
return [
'valid' => false,
'errors' => $validator->errors()->all(),
];
}
return ['valid' => true, 'errors' => []];
}
/**
* Get processing options from config.
*/
protected function getOption(string $key, mixed $default = null): mixed
{
return $this->entityConfig?->processing_options[$key] ?? $default;
}
/**
* Determine if a field has changed.
*/
protected function hasChanged($model, string $field, mixed $newValue): bool
{
$current = $model->{$field};
if (is_null($newValue) && is_null($current)) {
return false;
}
return $current != $newValue;
}
/**
* Track which fields were applied/changed.
*/
protected function trackAppliedFields($model, array $payload): array
{
$applied = [];
foreach ($payload as $field => $value) {
if ($this->hasChanged($model, $field, $value)) {
$applied[] = $field;
}
}
return $applied;
}
/**
* Default implementation returns null - override in specific handlers.
*/
public function resolve(array $mapped, array $context = []): mixed
{
return null;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Services\Import\Contracts;
use App\Models\Import;
interface EntityHandlerInterface
{
/**
* Process a single row for this entity.
*
* @param Import $import The import instance
* @param array $mapped Mapped data for this entity
* @param array $raw Raw row data
* @param array $context Additional context (previous entity results, etc.)
* @return array Result with action, entity instance, applied_fields, etc.
*/
public function process(Import $import, array $mapped, array $raw, array $context = []): array;
/**
* Validate mapped data before processing.
*
* @param array $mapped Mapped data for this entity
* @return array Validation result ['valid' => bool, 'errors' => array]
*/
public function validate(array $mapped): array;
/**
* Get the entity class name this handler manages.
*
* @return string
*/
public function getEntityClass(): string;
/**
* Resolve existing entity by key/reference.
*
* @param array $mapped Mapped data for this entity
* @param array $context Additional context
* @return mixed|null Existing entity instance or null
*/
public function resolve(array $mapped, array $context = []): mixed;
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Services\Import;
class DateNormalizer
{
/**
* Normalize a raw date string to Y-m-d (ISO) or return null if unparseable.
* Accepted examples: 30.10.2025, 30/10/2025, 30-10-2025, 1/2/25, 2025-10-30
*/
public static function toDate(?string $raw): ?string
{
if ($raw === null) {
return null;
}
$raw = trim($raw);
if ($raw === '') {
return null;
}
// Common European and ISO formats first (day-first, then ISO)
$candidates = [
'd.m.Y', 'd.m.y',
'd/m/Y', 'd/m/y',
'd-m-Y', 'd-m-y',
'Y-m-d', 'Y/m/d', 'Y.m.d',
];
foreach ($candidates as $fmt) {
$dt = \DateTime::createFromFormat($fmt, $raw);
if ($dt instanceof \DateTime) {
$errors = \DateTime::getLastErrors();
if ((int) ($errors['warning_count'] ?? 0) === 0 && (int) ($errors['error_count'] ?? 0) === 0) {
// Adjust two-digit years to reasonable century (00-69 => 2000-2069, 70-99 => 1970-1999)
$year = (int) $dt->format('Y');
if ($year < 100) {
$year += ($year <= 69) ? 2000 : 1900;
// Rebuild date with corrected year
$month = (int) $dt->format('m');
$day = (int) $dt->format('d');
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
return $dt->format('Y-m-d');
}
}
}
// Fallback: strtotime (permissive). If fails, return null.
$ts = @strtotime($raw);
if ($ts === false) {
return null;
}
return date('Y-m-d', $ts);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Services\Import;
class DecimalNormalizer
{
/**
* Normalize a raw decimal string to a standard format (period as decimal separator).
* Handles European format (comma as decimal) and American format (period as decimal).
*
* Examples:
* - "958,31" => "958.31"
* - "1.234,56" => "1234.56"
* - "1,234.56" => "1234.56"
* - "1234" => "1234"
*
* Based on ImportProcessor::normalizeDecimal()
*/
public static function normalize(?string $raw): ?string
{
if ($raw === null) {
return null;
}
// Keep digits, comma, dot, and minus to detect separators
$s = preg_replace('/[^0-9,\.-]/', '', $raw) ?? '';
$s = trim($s);
if ($s === '') {
return null;
}
$lastComma = strrpos($s, ',');
$lastDot = strrpos($s, '.');
// Determine decimal separator by last occurrence
$decimalSep = null;
if ($lastComma !== false || $lastDot !== false) {
if ($lastComma === false) {
$decimalSep = '.';
} elseif ($lastDot === false) {
$decimalSep = ',';
} else {
$decimalSep = $lastComma > $lastDot ? ',' : '.';
}
}
// Remove all thousand separators and unify decimal to '.'
if ($decimalSep === ',') {
// Remove all dots (thousand separators)
$s = str_replace('.', '', $s);
// Replace last comma with dot
$pos = strrpos($s, ',');
if ($pos !== false) {
$s[$pos] = '.';
}
// Remove any remaining commas (unlikely)
$s = str_replace(',', '', $s);
} elseif ($decimalSep === '.') {
// Remove all commas (thousand separators)
$s = str_replace(',', '', $s);
// Dot is already decimal separator
} else {
// No decimal separator: remove commas/dots entirely
$s = str_replace([',', '.'], '', $s);
}
// Handle negative numbers
$s = ltrim($s, '+');
$neg = false;
if (str_starts_with($s, '-')) {
$neg = true;
$s = ltrim($s, '-');
}
// Remove any stray minus signs
$s = str_replace('-', '', $s);
if ($neg) {
$s = '-' . $s;
}
return $s;
}
}
@@ -0,0 +1,399 @@
<?php
namespace App\Services\Import;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Email;
use App\Models\Import;
use App\Models\Person\Person;
use App\Models\Person\PersonAddress;
use App\Models\Person\PersonPhone;
use Illuminate\Support\Facades\Log;
/**
* EntityResolutionService - Resolves existing entities to prevent duplication.
*
* This service checks for existing entities before creating new ones,
* following the V1 deduplication hierarchy:
* 1. Contract reference ClientCase Person
* 2. ClientCase client_ref Person
* 3. Contact values (email/phone/address) Person
* 4. Person identifiers (tax_number/ssn) Person
*/
class EntityResolutionService
{
/**
* Resolve Person ID from import context (existing entities).
* Returns Person ID if found, null otherwise.
*
* @param Import $import
* @param array $mapped Mapped data from CSV row
* @param array $context Processing context with previously processed entities
* @return int|null Person ID if found, null if should create new
*/
public function resolvePersonFromContext(Import $import, array $mapped, array $context): ?int
{
// 1. Check if Contract already processed in this row
if ($contract = $context['contract']['entity'] ?? null) {
$personId = $this->getPersonFromContract($contract);
if ($personId) {
Log::info('EntityResolutionService: Found Person from processed Contract', [
'person_id' => $personId,
'contract_id' => $contract->id,
]);
return $personId;
}
}
// 2. Check if ClientCase already processed in this row
if ($clientCase = $context['client_case']['entity'] ?? null) {
if ($clientCase->person_id) {
Log::info('EntityResolutionService: Found Person from processed ClientCase', [
'person_id' => $clientCase->person_id,
'client_case_id' => $clientCase->id,
]);
return $clientCase->person_id;
}
}
// 3. Check for existing Contract by reference (before it's processed)
if ($contractRef = $mapped['contract']['reference'] ?? null) {
$personId = $this->getPersonFromContractReference($import->client_id, $contractRef);
if ($personId) {
Log::info('EntityResolutionService: Found Person from existing Contract reference', [
'person_id' => $personId,
'contract_reference' => $contractRef,
]);
return $personId;
}
}
// 4. Check for existing ClientCase by client_ref (before it's processed)
if ($clientRef = $mapped['client_case']['client_ref'] ?? null) {
$personId = $this->getPersonFromClientRef($import->client_id, $clientRef);
if ($personId) {
Log::info('EntityResolutionService: Found Person from existing ClientCase client_ref', [
'person_id' => $personId,
'client_ref' => $clientRef,
]);
return $personId;
}
}
// 5. Check for existing Person by contact values (email/phone/address)
$personId = $this->resolvePersonByContacts($mapped);
if ($personId) {
Log::info('EntityResolutionService: Found Person from contact values', [
'person_id' => $personId,
]);
return $personId;
}
// No existing Person found
return null;
}
/**
* Check if ClientCase exists for this client_ref.
*
* @param int|null $clientId
* @param string $clientRef
* @return bool
*/
public function clientCaseExists(?int $clientId, string $clientRef): bool
{
if (!$clientId || !$clientRef) {
return false;
}
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->exists();
}
/**
* Check if Contract exists for this reference.
*
* @param int|null $clientId
* @param string $reference
* @return bool
*/
public function contractExists(?int $clientId, string $reference): bool
{
if (!$clientId || !$reference) {
return false;
}
return Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->exists();
}
/**
* Get existing ClientCase by client_ref.
*
* @param int|null $clientId
* @param string $clientRef
* @return ClientCase|null
*/
public function getExistingClientCase(?int $clientId, string $clientRef): ?ClientCase
{
if (!$clientId || !$clientRef) {
return null;
}
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->first();
}
/**
* Get existing Contract by reference for this client.
*
* @param int|null $clientId
* @param string $reference
* @return Contract|null
*/
public function getExistingContract(?int $clientId, string $reference): ?Contract
{
if (!$clientId || !$reference) {
return null;
}
return 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();
}
/**
* Get Person ID from a Contract entity.
*
* @param Contract $contract
* @return int|null
*/
protected function getPersonFromContract(Contract $contract): ?int
{
if ($contract->client_case_id) {
return ClientCase::where('id', $contract->client_case_id)
->value('person_id');
}
return null;
}
/**
* Get Person ID from existing Contract by reference.
*
* @param int|null $clientId
* @param string $reference
* @return int|null
*/
protected 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;
}
/**
* Get Person ID from existing ClientCase by client_ref.
*
* @param int|null $clientId
* @param string $clientRef
* @return int|null
*/
protected function getPersonFromClientRef(?int $clientId, string $clientRef): ?int
{
if (!$clientId) {
return null;
}
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->value('person_id');
}
/**
* Resolve Person by contact values (email, phone, address).
* Checks existing contact records and returns associated Person ID.
*
* @param array $mapped
* @return int|null
*/
protected function resolvePersonByContacts(array $mapped): ?int
{
// Check email (support both single and array formats)
$email = $this->extractContactValue($mapped, 'email', 'value', 'emails');
if ($email) {
$personId = Email::where('value', trim($email))->value('person_id');
if ($personId) {
return $personId;
}
}
// Check phone (support both single and array formats)
$phone = $this->extractContactValue($mapped, 'phone', 'nu', 'person_phones');
if ($phone) {
$personId = PersonPhone::where('nu', trim($phone))->value('person_id');
if ($personId) {
return $personId;
}
}
// Check address (support both single and array formats)
$address = $this->extractContactValue($mapped, 'address', 'address', 'person_addresses');
if ($address) {
$personId = PersonAddress::where('address', trim($address))->value('person_id');
if ($personId) {
return $personId;
}
}
return null;
}
/**
* Extract contact value from mapped data, supporting multiple formats.
*
* @param array $mapped
* @param string $singularKey e.g., 'email', 'phone', 'address'
* @param string $field Field name within the contact data
* @param string $pluralKey e.g., 'emails', 'person_phones', 'person_addresses'
* @return string|null
*/
protected function extractContactValue(array $mapped, string $singularKey, string $field, string $pluralKey): ?string
{
// Try singular key first (e.g., 'email')
if (isset($mapped[$singularKey][$field])) {
return $mapped[$singularKey][$field];
}
// Try plural key (e.g., 'emails')
if (isset($mapped[$pluralKey])) {
// If it's an array of contacts
if (is_array($mapped[$pluralKey])) {
// Try first element if it's an indexed array
if (isset($mapped[$pluralKey][0][$field])) {
return $mapped[$pluralKey][0][$field];
}
// Try direct field access if it's a single hash
if (isset($mapped[$pluralKey][$field])) {
return $mapped[$pluralKey][$field];
}
}
}
return null;
}
/**
* Check if this row should skip Person creation based on existing entities.
* Used by PersonHandler to determine if Person already exists via chain.
*
* @param Import $import
* @param array $mapped
* @param array $context
* @return bool True if Person should be skipped (already exists)
*/
public function shouldSkipPersonCreation(Import $import, array $mapped, array $context): bool
{
// If we can resolve existing Person, we should skip creation
$personId = $this->resolvePersonFromContext($import, $mapped, $context);
return $personId !== null;
}
/**
* Get or create ClientCase for Contract creation.
* Reuses existing ClientCase if found by client_ref.
*
* @param Import $import
* @param array $mapped
* @param array $context
* @return int|null ClientCase ID
*/
public function resolveOrCreateClientCaseForContract(Import $import, array $mapped, array $context): ?int
{
$clientId = $import->client_id;
if (!$clientId) {
return null;
}
// If ClientCase already processed in this row, use it
if ($clientCaseId = $context['client_case']['entity']?->id ?? null) {
return $clientCaseId;
}
// Try to find by client_ref
$clientRef = $mapped['client_case']['client_ref'] ?? $mapped['client_ref'] ?? null;
if ($clientRef) {
$existing = $this->getExistingClientCase($clientId, $clientRef);
if ($existing) {
Log::info('EntityResolutionService: Reusing existing ClientCase for Contract', [
'client_case_id' => $existing->id,
'client_ref' => $clientRef,
]);
return $existing->id;
}
}
// Need to create new ClientCase
// Get Person from context (should be processed before Contract now)
$personId = $context['person']['entity']?->id ?? null;
if (!$personId) {
// Person wasn't in import or wasn't found, try to resolve
$personId = $this->resolvePersonFromContext($import, $mapped, $context);
if (!$personId) {
// Create minimal Person as last resort
$defaultGroupId = (int) (\App\Models\Person\PersonGroup::min('id') ?? 1);
$personId = Person::create([
'type_id' => 1,
'group_id' => $defaultGroupId,
])->id;
Log::info('EntityResolutionService: Created minimal Person for new ClientCase', [
'person_id' => $personId,
'group_id' => $defaultGroupId,
]);
}
}
$clientCase = ClientCase::create([
'client_id' => $clientId,
'person_id' => $personId,
'client_ref' => $clientRef,
]);
Log::info('EntityResolutionService: Created new ClientCase', [
'client_case_id' => $clientCase->id,
'person_id' => $personId,
'client_ref' => $clientRef,
]);
return $clientCase->id;
}
}
@@ -0,0 +1,216 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Account;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
use App\Services\Import\DecimalNormalizer;
class AccountHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Account::class;
}
/**
* Override validate to handle contract_id and reference from context.
* Both contract_id and reference are populated in process() (reference defaults to contract reference).
*/
public function validate(array $mapped): array
{
// Remove contract_id and reference from validation - both will be populated in process()
// Reference defaults to contract.reference if not set (matching v1 behavior)
$rules = $this->entityConfig?->validation_rules ?? [];
unset($rules['contract_id'], $rules['reference']);
if (empty($rules)) {
return ['valid' => true, 'errors' => []];
}
$validator = \Illuminate\Support\Facades\Validator::make($mapped, $rules);
if ($validator->fails()) {
return [
'valid' => false,
'errors' => $validator->errors()->all(),
];
}
return ['valid' => true, 'errors' => []];
}
public function resolve(array $mapped, array $context = []): mixed
{
$reference = $mapped['reference'] ?? null;
$contractId = $mapped['contract_id'] ?? $context['contract']['entity']->id ?? null;
if (! $reference || ! $contractId) {
return null;
}
return Account::where('contract_id', $contractId)
->where('reference', $reference)
->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Ensure contract context
if (! isset($context['contract'])) {
return [
'action' => 'skipped',
'message' => 'Account requires contract context',
];
}
// Fallback: if account.reference is empty, use contract.reference (matching v1 behavior)
if (empty($mapped['reference'])) {
$contractReference = $context['contract']['entity']->reference ?? null;
if ($contractReference) {
$mapped['reference'] = preg_replace('/\s+/', '', trim((string) $contractReference));
}
}
$contractId = $context['contract']['entity']->id;
$mapped['contract_id'] = $contractId;
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Track old balance for activity creation
$oldBalance = (float) ($existing->balance_amount ?? 0);
$payload = $this->buildPayload($mapped, $existing);
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
// Create activity if balance changed and tracking is enabled
if ($this->getOption('track_balance_changes', true) && array_key_exists('balance_amount', $appliedFields)) {
$this->createBalanceChangeActivity($existing, $oldBalance, $import, $context);
}
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create new account
$account = new Account;
$payload = $this->buildPayload($mapped, $account);
// Ensure required defaults for new accounts
if (!isset($payload['type_id'])) {
$payload['type_id'] = $this->getDefaultAccountTypeId();
}
$account->fill($payload);
$account->save();
return [
'action' => 'inserted',
'entity' => $account,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
$fieldMap = [
'contract_id' => 'contract_id',
'reference' => 'reference',
'title' => 'title',
'description' => 'description',
'balance_amount' => 'balance_amount',
'currency' => 'currency',
];
foreach ($fieldMap as $source => $target) {
if (array_key_exists($source, $mapped)) {
$value = $mapped[$source];
// Normalize decimal fields (convert comma to period)
if (in_array($source, ['balance_amount', 'initial_amount']) && is_string($value)) {
$value = DecimalNormalizer::normalize($value);
}
$payload[$target] = $value;
}
}
return $payload;
}
/**
* Create activity when account balance changes.
*/
protected function createBalanceChangeActivity(Account $account, float $oldBalance, Import $import, array $context): void
{
if (! $this->getOption('create_activity_on_balance_change', true)) {
return;
}
try {
$newBalance = (float) ($account->balance_amount ?? 0);
// Skip if balance didn't actually change
if ($newBalance === $oldBalance) {
return;
}
$currency = \App\Models\PaymentSetting::first()?->default_currency ?? 'EUR';
$beforeStr = number_format($oldBalance, 2, ',', '.').' '.$currency;
$afterStr = number_format($newBalance, 2, ',', '.').' '.$currency;
$note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)';
// Get client_case_id
$clientCaseId = $account->contract?->client_case_id;
if ($clientCaseId) {
// Use action_id from import meta if available
$metaActionId = (int) ($import->meta['action_id'] ?? 0);
if ($metaActionId > 0) {
\App\Models\Activity::create([
'due_date' => null,
'amount' => null,
'note' => $note,
'action_id' => $metaActionId,
'decision_id' => $import->meta['decision_id'] ?? null,
'client_case_id' => $clientCaseId,
'contract_id' => $account->contract_id,
]);
}
}
} catch (\Throwable $e) {
\Log::warning('Failed to create balance change activity', [
'account_id' => $account->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Get default account type ID.
*/
protected function getDefaultAccountTypeId(): int
{
return (int) (\App\Models\AccountType::min('id') ?? 1);
}
}
@@ -0,0 +1,171 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Activity;
use App\Models\Import;
use App\Services\Import\DateNormalizer;
use App\Services\Import\BaseEntityHandler;
class ActivityHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Activity::class;
}
/**
* Override validate to skip validation if note is empty.
* Handles both single values and arrays.
*/
public function validate(array $mapped): array
{
$note = $mapped['note'] ?? null;
// If array, check if all values are empty
if (is_array($note)) {
$hasValue = false;
foreach ($note as $n) {
if (!empty($n) && trim((string)$n) !== '') {
$hasValue = true;
break;
}
}
if (!$hasValue) {
return ['valid' => true, 'errors' => []];
}
// Skip parent validation for arrays - we'll validate in process()
return ['valid' => true, 'errors' => []];
}
// Single value - check if empty
if (empty($note) || trim((string)$note) === '') {
return ['valid' => true, 'errors' => []];
}
return parent::validate($mapped);
}
public function resolve(array $mapped, array $context = []): mixed
{
// Activities typically don't have a unique reference for deduplication
// Override this method if you have specific deduplication logic
return null;
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Handle multiple activities if note is an array
$notes = $mapped['note'] ?? null;
// If single value, convert to array for uniform processing
if (!is_array($notes)) {
$notes = [$notes];
}
$results = [];
$insertedCount = 0;
$skippedCount = 0;
// Get context IDs once
$clientCaseId = $mapped['client_case_id'] ?? $context['contract']['entity']?->client_case_id ?? null;
$contractId = $mapped['contract_id'] ?? $context['contract']['entity']?->id ?? null;
foreach ($notes as $note) {
// Skip if note is empty
if (empty($note) || trim((string)$note) === '') {
$skippedCount++;
continue;
}
// Require at least client_case_id or contract_id based on options
$requireCase = $this->getOption('require_client_case', false);
$requireContract = $this->getOption('require_contract', false);
if ($requireCase && ! $clientCaseId) {
$skippedCount++;
continue;
}
if ($requireContract && ! $contractId) {
$skippedCount++;
continue;
}
// Build activity payload for this note
$payload = ['note' => $note];
$payload['client_case_id'] = $clientCaseId;
$payload['contract_id'] = $contractId;
// Set action_id and decision_id from template meta if not in mapped data
if (!isset($mapped['action_id'])) {
$payload['action_id'] = $import->template->meta['activity_action_id'] ?? $this->getDefaultActionId();
} else {
$payload['action_id'] = $mapped['action_id'];
}
if (!isset($mapped['decision_id']) && isset($import->template->meta['activity_decision_id'])) {
$payload['decision_id'] = $import->template->meta['activity_decision_id'];
}
// Create activity
$activity = new \App\Models\Activity;
$activity->fill($payload);
$activity->save();
$results[] = $activity;
$insertedCount++;
}
if ($insertedCount === 0 && $skippedCount > 0) {
return [
'action' => 'skipped',
'message' => 'All activities empty or missing requirements',
];
}
return [
'action' => 'inserted',
'entity' => $results[0] ?? null,
'entities' => $results,
'applied_fields' => ['note', 'client_case_id', 'contract_id', 'action_id'],
'count' => $insertedCount,
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
// Map activity fields
if (isset($mapped['due_date'])) {
$payload['due_date'] = DateNormalizer::toDate((string) $mapped['due_date']);
}
if (isset($mapped['amount'])) {
$payload['amount'] = is_string($mapped['amount']) ? (float) str_replace(',', '.', $mapped['amount']) : (float) $mapped['amount'];
}
if (isset($mapped['note'])) {
$payload['note'] = $mapped['note'];
}
if (isset($mapped['action_id'])) {
$payload['action_id'] = (int) $mapped['action_id'];
}
if (isset($mapped['decision_id'])) {
$payload['decision_id'] = (int) $mapped['decision_id'];
}
return $payload;
}
/**
* Get default action ID (use minimum ID from actions table).
*/
private function getDefaultActionId(): int
{
return (int) (\App\Models\Action::min('id') ?? 1);
}
}
@@ -0,0 +1,144 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Import;
use App\Models\Person\PersonAddress;
use App\Services\Import\BaseEntityHandler;
class AddressHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return PersonAddress::class;
}
/**
* Override validate to skip validation if address is empty.
* Handles both single values and arrays.
*/
public function validate(array $mapped): array
{
$address = $mapped['address'] ?? null;
// If array, check if all values are empty
if (is_array($address)) {
$hasValue = false;
foreach ($address as $addr) {
if (!empty($addr) && trim((string)$addr) !== '') {
$hasValue = true;
break;
}
}
if (!$hasValue) {
return ['valid' => true, 'errors' => []];
}
// Skip parent validation for arrays - we'll validate in process()
return ['valid' => true, 'errors' => []];
}
// Single value - check if empty
if (empty($address) || trim((string)$address) === '') {
return ['valid' => true, 'errors' => []];
}
return parent::validate($mapped);
}
public function resolve(array $mapped, array $context = []): mixed
{
$address = $mapped['address'] ?? null;
$personId = $mapped['person_id']
?? ($context['person']['entity']->id ?? null)
?? ($context['person']?->entity?->id ?? null);
if (! $address || ! $personId) {
return null;
}
// Find existing address by exact match for this person
return PersonAddress::where('person_id', $personId)
->where('address', $address)
->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Handle multiple addresses if address is an array
$addresses = $mapped['address'] ?? null;
// If single value, convert to array for uniform processing
if (!is_array($addresses)) {
$addresses = [$addresses];
}
$results = [];
$insertedCount = 0;
$skippedCount = 0;
foreach ($addresses as $address) {
// Skip if address is empty or blank
if (empty($address) || trim((string)$address) === '') {
$skippedCount++;
continue;
}
// Resolve person_id from context
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
if (! $personId) {
$skippedCount++;
continue;
}
$existing = $this->resolveAddress($address, $personId);
// Check for duplicates if configured
if ($this->getOption('deduplicate', true) && $existing) {
$skippedCount++;
continue;
}
// Create new address
$payload = $this->buildPayloadForAddress($address);
$payload['person_id'] = $personId;
$addressEntity = new PersonAddress;
$addressEntity->fill($payload);
$addressEntity->save();
$results[] = $addressEntity;
$insertedCount++;
}
if ($insertedCount === 0 && $skippedCount > 0) {
return [
'action' => 'skipped',
'message' => 'All addresses empty or duplicates',
];
}
return [
'action' => 'inserted',
'entity' => $results[0] ?? null,
'entities' => $results,
'applied_fields' => ['address', 'person_id'],
'count' => $insertedCount,
];
}
protected function resolveAddress(string $address, int $personId): mixed
{
return PersonAddress::where('person_id', $personId)
->where('address', $address)
->first();
}
protected function buildPayloadForAddress(string $address): array
{
return [
'address' => $address,
'type_id' => 1, // Default to permanent address
];
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\CaseObject;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
class CaseObjectHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return CaseObject::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
$reference = $mapped['reference'] ?? null;
$name = $mapped['name'] ?? null;
if (! $reference && ! $name) {
return null;
}
// Try to find by reference first
if ($reference) {
$object = CaseObject::where('reference', $reference)->first();
if ($object) {
return $object;
}
}
// Fall back to name if reference not found
if ($name) {
return CaseObject::where('name', $name)->first();
}
return null;
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Update existing object
$payload = $this->buildPayload($mapped, $existing);
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create new case object
$payload = $this->buildPayload($mapped, new CaseObject);
$caseObject = new CaseObject;
$caseObject->fill($payload);
$caseObject->save();
return [
'action' => 'inserted',
'entity' => $caseObject,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
$fields = ['reference', 'name', 'description', 'type', 'contract_id'];
foreach ($fields as $field) {
if (array_key_exists($field, $mapped)) {
$payload[$field] = $mapped[$field];
}
}
return $payload;
}
}
@@ -0,0 +1,163 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\ClientCase;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
use App\Services\Import\EntityResolutionService;
use Illuminate\Support\Facades\Log;
class ClientCaseHandler extends BaseEntityHandler
{
protected EntityResolutionService $resolutionService;
public function __construct($entityConfig = null)
{
parent::__construct($entityConfig);
$this->resolutionService = new EntityResolutionService();
}
public function getEntityClass(): string
{
return ClientCase::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
$clientRef = $mapped['client_ref'] ?? null;
$clientId = $context['import']?->client_id ?? null;
if (! $clientRef || ! $clientId) {
return null;
}
// Find existing case by client_ref for this client
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
$clientId = $import->client_id ?? null;
if (! $clientId) {
return [
'action' => 'skipped',
'message' => 'ClientCase requires client_id',
];
}
// PHASE 5: Use Person from context (already processed due to reversed priorities)
// Priority order: explicit person_id > context person > resolved person
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
// If no Person in context, try to resolve using EntityResolutionService
if (!$personId) {
$personId = $this->resolutionService->resolvePersonFromContext($import, $mapped, $context);
if ($personId) {
Log::info('ClientCaseHandler: Resolved Person via EntityResolutionService', [
'person_id' => $personId,
]);
} else {
Log::warning('ClientCaseHandler: No Person found in context or via resolution', [
'has_person_context' => isset($context['person']),
'has_mapped_person_id' => isset($mapped['person_id']),
]);
}
} else {
Log::info('ClientCaseHandler: Using Person from context/mapping', [
'person_id' => $personId,
'source' => $mapped['person_id'] ? 'mapped' : 'context',
]);
}
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Update if configured
$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 if provided and different
if ($personId && $existing->person_id !== $personId) {
$payload['person_id'] = $personId;
}
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
Log::info('ClientCaseHandler: Updated existing ClientCase', [
'client_case_id' => $existing->id,
'person_id' => $existing->person_id,
'applied_fields' => $appliedFields,
]);
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create new client case
$payload = $this->buildPayload($mapped, new ClientCase);
$payload['client_id'] = $clientId;
if ($personId) {
$payload['person_id'] = $personId;
}
$clientCase = new ClientCase;
$clientCase->fill($payload);
$clientCase->save();
Log::info('ClientCaseHandler: Created new ClientCase', [
'client_case_id' => $clientCase->id,
'person_id' => $clientCase->person_id,
'client_ref' => $clientCase->client_ref,
]);
return [
'action' => 'inserted',
'entity' => $clientCase,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
$fields = ['client_ref'];
foreach ($fields as $field) {
if (array_key_exists($field, $mapped)) {
$payload[$field] = $mapped[$field];
}
}
return $payload;
}
}

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