Compare commits
124 Commits
3b284fa4bd
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ea9376c713 | |||
| 8ffc60aba5 | |||
| 7ab890005b | |||
| b6405764a9 | |||
| 256b311c43 | |||
| 32fe2fbc9b | |||
| e3bc5da7e3 | |||
| b6bfa17980 | |||
| fd81e8ce2d | |||
| 054202dc32 | |||
| 92f54f7103 | |||
| 8f8c5c5a12 | |||
| 187cb4f127 | |||
| 7881508a7b | |||
| 821985469e | |||
| a5257df2b7 | |||
| 342d9d0700 | |||
| d54fc9914d | |||
| f8d1579cb2 | |||
| d80c99c6c0 | |||
| 9c773be3ec | |||
| 9c6878d1bd | |||
| 5f9d00b575 | |||
| 2cc765912e | |||
| b6e66f0e64 | |||
| 0aa95fba47 | |||
| 0b082549b9 | |||
| b0d2aa93ab | |||
| c16dd51199 | |||
| 245caea4dc | |||
| dda118a005 | |||
| 8147fedd04 | |||
| b1c531bb70 | |||
| 9cc1b7072c | |||
| 2968bcf3f8 | |||
| ad0f7a7a01 | |||
| 368b0a7cf7 | |||
| aa375ce0da | |||
| 340e16c610 | |||
| 33b236d881 | |||
| fb7704027b | |||
| e5902706f1 | |||
| 229c100cc4 | |||
| 9a4897bf0c | |||
| d779e4d7a1 | |||
| b2a9350d0f | |||
| d64a67cf76 | |||
| 068bbdf583 | |||
| cc4c07717e | |||
| 28f28be1b8 | |||
| 27bdb942ab | |||
| ebf9f29200 | |||
| 7eaab16e30 | |||
| 6a2dd860fa | |||
| 091fb07646 | |||
| 357a254e82 | |||
| aa93c96d31 | |||
| ca8754cd94 | |||
| 8fdc0d6359 | |||
| df6c3133ec | |||
| f646b6530a | |||
| 7fc4520dbf | |||
| f66bbbf842 | |||
| 4f605451e1 | |||
| dc41862afc | |||
| c4d2f6e473 | |||
| 711438d79f | |||
| fb6474ab88 | |||
| 6871fe8796 | |||
| 137e0b45ad | |||
| 2ad24216ae | |||
| c4d9ecb39e | |||
| 70a5d015e0 | |||
| 8031501d25 | |||
| 703b52ff59 | |||
| 9fc5b54b8a | |||
| 082a637719 | |||
| b9f66cbfbe | |||
| 36b63a180d | |||
| 84b75143df | |||
| dea7432deb | |||
| f8623a6071 | |||
| ee641586c3 | |||
| adc2a64687 | |||
| 11206fb4f7 | |||
| 39a597f6eb | |||
| 5d4498ac5a | |||
| 622f53e401 | |||
| 96473fd60b | |||
| 5ddca35389 | |||
| 94ad0c0772 | |||
| 2140181a76 | |||
| 06fa443b3e | |||
| 6c45063e47 | |||
| b8c9b51f29 | |||
| a4db37adfa | |||
| 76f76f73b4 | |||
| 85922bdac0 | |||
| d69f4dd6f6 | |||
| 3291e9b439 | |||
| a596177a68 | |||
| c7164be323 | |||
| 80948d2944 | |||
| aa40ebed5c | |||
| 79de54eef0 | |||
| 53941c054e | |||
| 1a7d2793b0 | |||
| a6ec92ec6b | |||
| fa54cf48f3 | |||
| e10990411e | |||
| 37205e0dea | |||
| d2287ef963 | |||
| 70dc0b893f | |||
| f5530edcea | |||
| fb7160eb33 | |||
| c4a78b4632 | |||
| c1ac92efbf | |||
| c3de189e9d | |||
| 44f9f8f9fa | |||
| edbdb64102 | |||
| 8125b4d321 | |||
| 46feba2df7 | |||
| 1395b72ae8 | |||
| ad8e0d5cee |
@@ -0,0 +1,29 @@
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.gitattributes
|
||||
.env
|
||||
.env.*
|
||||
!.env.production.example
|
||||
node_modules
|
||||
npm-debug.log
|
||||
vendor
|
||||
storage/app/*
|
||||
storage/framework/cache/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
bootstrap/cache/*
|
||||
public/storage
|
||||
public/hot
|
||||
*.md
|
||||
!README.md
|
||||
tests
|
||||
.phpunit.result.cache
|
||||
phpunit.xml
|
||||
docker-compose*.yml
|
||||
.editorconfig
|
||||
.styleci.yml
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,82 @@
|
||||
APP_NAME="Teren App"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://localhost:8080
|
||||
|
||||
APP_LOCALE=sl
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=sl_SI
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
APP_MAINTENANCE_STORE=database
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Database
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=teren_app
|
||||
DB_USERNAME=teren_user
|
||||
DB_PASSWORD=local_password
|
||||
|
||||
# Redis
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=
|
||||
SESSION_SECURE_COOKIE=false
|
||||
SESSION_SAME_SITE=lax
|
||||
|
||||
# Cache
|
||||
CACHE_STORE=redis
|
||||
|
||||
# Mail (Mailpit for local testing)
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=mailpit
|
||||
MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
SCOUT_PREFIX=
|
||||
SCOUT_QUEUE=true
|
||||
|
||||
# Sanctum
|
||||
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,localhost:8080,127.0.0.1:8080
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Vite
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_DEV_SERVER_KEY=
|
||||
VITE_DEV_SERVER_CERT=
|
||||
|
||||
# LibreOffice for document previews (Docker container path)
|
||||
LIBREOFFICE_BIN=/usr/bin/soffice
|
||||
|
||||
# Storage configuration for generated previews
|
||||
FILES_PREVIEW_DISK=public
|
||||
FILES_PREVIEW_BASE=previews/casesNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Vite
|
||||
VITE_DEV_SERVER_KEY=
|
||||
VITE_DEV_SERVER_CERT=
|
||||
@@ -0,0 +1,88 @@
|
||||
APP_NAME="Teren App"
|
||||
APP_ENV=production
|
||||
APP_KEY= # Generate with: php artisan key:generate
|
||||
APP_DEBUG=false
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=https://example.com # Your domain
|
||||
|
||||
APP_LOCALE=sl
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=sl_SI
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
APP_MAINTENANCE_STORE=database
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Database
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=teren_app
|
||||
DB_USERNAME=teren_user
|
||||
DB_PASSWORD= # Generate a strong password
|
||||
|
||||
# Redis
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=
|
||||
SESSION_SECURE_COOKIE=true
|
||||
SESSION_SAME_SITE=lax
|
||||
|
||||
# Cache
|
||||
CACHE_STORE=redis
|
||||
|
||||
# pgAdmin
|
||||
PGADMIN_EMAIL=admin@example.com
|
||||
PGADMIN_PASSWORD= # Generate a strong password
|
||||
|
||||
# WireGuard VPN (REQUIRED - app is VPN-only)
|
||||
WG_SERVERURL=vpn.example.com # Your VPS public IP or domain
|
||||
WG_UI_PASSWORD= # Generate a strong password for WireGuard dashboard
|
||||
|
||||
# Mail (configure as needed)
|
||||
MAIL_MAILER=log
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PA
|
||||
SCOUT_DRIVER=database
|
||||
SCOUT_PREFIX=
|
||||
SCOUT_QUEUE=true
|
||||
|
||||
# Sanctum
|
||||
SANCTUM_STATEFUL_DOMAINS=example.com,www.example.com,10.13.13.1
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=error
|
||||
|
||||
# Vite
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# LibreOffice for document previews (Docker container path)
|
||||
LIBREOFFICE_BIN=/usr/bin/soffice
|
||||
|
||||
# Storage configuration for generated previews
|
||||
FILES_PREVIEW_DISK=public
|
||||
FILES_PREVIEW_BASE=previews/cases
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=error
|
||||
+20
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+83
@@ -0,0 +1,83 @@
|
||||
ARG PHP_VERSION=8.4
|
||||
FROM php:${PHP_VERSION}-fpm-alpine
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
curl \
|
||||
zip \
|
||||
unzip \
|
||||
supervisor \
|
||||
nginx \
|
||||
postgresql-dev \
|
||||
libpng-dev \
|
||||
libjpeg-turbo-dev \
|
||||
freetype-dev \
|
||||
libwebp-dev \
|
||||
oniguruma-dev \
|
||||
libxml2-dev \
|
||||
linux-headers \
|
||||
${PHPIZE_DEPS}
|
||||
|
||||
# Configure and install PHP extensions
|
||||
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
|
||||
&& docker-php-ext-install -j$(nproc) \
|
||||
pdo_pgsql \
|
||||
pgsql \
|
||||
mbstring \
|
||||
exif \
|
||||
pcntl \
|
||||
bcmath \
|
||||
gd \
|
||||
opcache
|
||||
|
||||
# Install Redis extension via PECL
|
||||
RUN pecl install redis \
|
||||
&& docker-php-ext-enable redis
|
||||
|
||||
# Install LibreOffice from community repository
|
||||
RUN apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community \
|
||||
libreoffice-common \
|
||||
libreoffice-writer \
|
||||
libreoffice-calc
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Create system user to run Composer and Artisan Commands
|
||||
RUN addgroup -g 1000 -S www && \
|
||||
adduser -u 1000 -S www -G www
|
||||
|
||||
# Copy application files (will be overridden by volume mount in local development)
|
||||
COPY --chown=www:www . /var/www
|
||||
|
||||
# Copy supervisor configuration
|
||||
COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
COPY docker/supervisor/conf.d /etc/supervisor/conf.d
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R www:www /var/www \
|
||||
&& chmod -R 755 /var/www/storage \
|
||||
&& chmod -R 755 /var/www/bootstrap/cache
|
||||
|
||||
# PHP Configuration for production
|
||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||
|
||||
# Copy PHP custom configuration
|
||||
COPY docker/php/custom.ini $PHP_INI_DIR/conf.d/custom.ini
|
||||
|
||||
# Configure PHP-FPM to listen on all interfaces (0.0.0.0) instead of just localhost
|
||||
# This is needed for nginx running in a separate container to reach PHP-FPM
|
||||
RUN sed -i 's/listen = 127.0.0.1:9000/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf
|
||||
|
||||
# Expose port 9000 for PHP-FPM
|
||||
EXPOSE 9000
|
||||
|
||||
# Create directories for supervisor logs
|
||||
RUN mkdir -p /var/log/supervisor
|
||||
|
||||
# Start supervisor (which will manage both PHP-FPM and Laravel queue workers)
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
@@ -0,0 +1,343 @@
|
||||
# Local Testing Guide - Windows/Mac/Linux
|
||||
|
||||
This guide helps you test the Teren App Docker setup on your local machine without WireGuard VPN.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Prerequisites
|
||||
|
||||
- Docker Desktop installed and running
|
||||
- Git
|
||||
- 8GB RAM recommended
|
||||
- Ports available: 8080, 5433 (PostgreSQL), 5050, 6379, 9000, 8025, 1025
|
||||
- **Note:** If you have local PostgreSQL on port 5432, the Docker container uses 5433 instead
|
||||
|
||||
### 2. Setup
|
||||
|
||||
```bash
|
||||
# Clone repository (if not already)
|
||||
git clone YOUR_GITEA_URL
|
||||
cd Teren-app
|
||||
|
||||
# Copy local environment file
|
||||
cp .env.local.example .env
|
||||
|
||||
# Start all services
|
||||
docker compose -f docker-compose.local.yaml up -d
|
||||
|
||||
# Wait for services to start (30 seconds)
|
||||
timeout 30
|
||||
|
||||
# Generate application key
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan key:generate
|
||||
|
||||
# Run migrations
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan migrate
|
||||
|
||||
# Seed database (optional)
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan db:seed
|
||||
|
||||
# Install frontend dependencies (if needed)
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. Access Services
|
||||
|
||||
| Service | URL | Credentials |
|
||||
|---------|-----|-------------|
|
||||
| **Laravel App** | http://localhost:8080 | - |
|
||||
| **Portainer** | http://localhost:9000 | Set on first visit |
|
||||
| **pgAdmin** | http://localhost:5050 | admin@local.dev / admin |
|
||||
| **Mailpit** | http://localhost:8025 | - |
|
||||
| **PostgreSQL** | localhost:5433 | teren_user / local_password |
|
||||
| **Redis** | localhost:6379 | - |
|
||||
|
||||
**Note:** PostgreSQL uses port 5433 to avoid conflicts with any local PostgreSQL installation.
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Docker Compose Commands
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker compose -f docker-compose.local.yaml up -d
|
||||
|
||||
# Stop all services
|
||||
docker compose -f docker-compose.local.yaml down
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.local.yaml logs -f
|
||||
|
||||
# View specific service logs
|
||||
docker compose -f docker-compose.local.yaml logs -f app
|
||||
|
||||
# Restart a service
|
||||
docker compose -f docker-compose.local.yaml restart app
|
||||
|
||||
# Rebuild containers
|
||||
docker compose -f docker-compose.local.yaml up -d --build
|
||||
|
||||
# Stop and remove everything (including volumes)
|
||||
docker compose -f docker-compose.local.yaml down -v
|
||||
```
|
||||
|
||||
### Laravel Commands
|
||||
|
||||
```bash
|
||||
# Run artisan commands
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan [command]
|
||||
|
||||
# Examples:
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan migrate
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan db:seed
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan cache:clear
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan config:clear
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan queue:work
|
||||
|
||||
# Run tests
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan test
|
||||
|
||||
# Access container shell
|
||||
docker compose -f docker-compose.local.yaml exec app sh
|
||||
|
||||
# Run Composer commands
|
||||
docker compose -f docker-compose.local.yaml exec app composer install
|
||||
docker compose -f docker-compose.local.yaml exec app composer update
|
||||
```
|
||||
|
||||
### Database Commands
|
||||
|
||||
```bash
|
||||
# Connect to PostgreSQL (from inside container)
|
||||
docker compose -f docker-compose.local.yaml exec postgres psql -U teren_user -d teren_app
|
||||
|
||||
# Connect from Windows host
|
||||
psql -h localhost -p 5433 -U teren_user -d teren_app
|
||||
|
||||
# Backup database
|
||||
docker compose -f docker-compose.local.yaml exec postgres pg_dump -U teren_user teren_app > backup.sql
|
||||
|
||||
# Restore database
|
||||
docker compose -f docker-compose.local.yaml exec -T postgres psql -U teren_user teren_app < backup.sql
|
||||
|
||||
# Reset database
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan migrate:fresh --seed
|
||||
```
|
||||
|
||||
## pgAdmin Setup
|
||||
|
||||
1. Open http://localhost:5050
|
||||
2. Login: `admin@local.dev` / `admin`
|
||||
3. Add Server:
|
||||
- **General Tab:**
|
||||
- Name: `Teren Local`
|
||||
- **Connection Tab:**
|
||||
- Host: `postgres`
|
||||
- Port: `5432`
|
||||
- Database: `teren_app`
|
||||
- Username: `teren_user`
|
||||
- Passwo
|
||||
|
||||
**External Connection:** To connect from your Windows machine (e.g., DBeaver, pgAdmin desktop), use:
|
||||
- Host: `localhost`
|
||||
- Port: `5433` (not 5432)
|
||||
- Database: `teren_app`
|
||||
- Username: `teren_user`
|
||||
- Password: `local_password`rd: `local_password`
|
||||
4. Click Save
|
||||
|
||||
## Mailpit - Email Testing
|
||||
|
||||
All emails sent by the application are caught by Mailpit.
|
||||
|
||||
- Access: http://localhost:8025
|
||||
- View all emails in the web interface
|
||||
- Test email sending:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan tinker
|
||||
# In tinker:
|
||||
Mail::raw('Test email', function($msg) {
|
||||
$msg->to('test@example.com')->subject('Test');
|
||||
});
|
||||
```
|
||||
|
||||
## Portainer Setup
|
||||
|
||||
1. Open http://localhost:9000
|
||||
2. On first visit, create admin account
|
||||
3. Select "Docker" environment
|
||||
4. Click "Connect"
|
||||
|
||||
Use Portainer to:
|
||||
- View and manage containers
|
||||
- Check logs
|
||||
- Execute commands in containers
|
||||
- Monitor resource usage
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Frontend Development
|
||||
|
||||
The local setup supports live reloading:
|
||||
|
||||
```bash
|
||||
# Run Vite dev server (outside Docker)
|
||||
npm run dev
|
||||
|
||||
# Or inside Docker
|
||||
docker compose -f docker-compose.local.yaml exec app npm run dev
|
||||
```
|
||||
|
||||
Access: http://localhost:8080
|
||||
|
||||
### Code Changes
|
||||
|
||||
All code changes are automatically reflected because the source code is mounted as a volume:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./:/var/www # Live code mounting
|
||||
```
|
||||
|
||||
### Queue Workers
|
||||
|
||||
Queue workers are running via Supervisor inside the container. To restart:
|
||||
|
||||
```bash
|
||||
# Restart queue workers
|
||||
docker compose -f docker-compose.local.yaml exec app supervisorctl restart all
|
||||
|
||||
# Check status
|
||||
docker compose -f docker-compose.local.yaml exec app supervisorctl status
|
||||
|
||||
# View worker logs
|
||||
docker compose -f docker-compose.local.yaml exec app tail -f storage/logs/worker.log
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
If you get "port is already allocated" error:
|
||||
|
||||
```bash
|
||||
# Windows - Find process using port
|
||||
netstat -ano | findstr :8080
|
||||
|
||||
# Kill process by PID
|
||||
taskkill /PID <PID> /F
|
||||
|
||||
# Or change port in docker-compose.local.yaml
|
||||
ports:
|
||||
- "8081:80" # Change 8080 to 8081
|
||||
```
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose -f docker-compose.local.yaml logs app
|
||||
|
||||
# Rebuild containers
|
||||
docker compose -f docker-compose.local.yaml down
|
||||
docker compose -f docker-compose.local.yaml up -d --build
|
||||
```
|
||||
|
||||
### Permission Errors (Linux/Mac)
|
||||
|
||||
```bash
|
||||
# Fix storage permissions
|
||||
docker compose -f docker-compose.local.yaml exec app chown -R www:www /var/www/storage
|
||||
docker compose -f docker-compose.local.yaml exec app chmod -R 775 /var/www/storage
|
||||
```
|
||||
|
||||
### Database Connection Failed
|
||||
|
||||
```bash
|
||||
# Check if PostgreSQL is running
|
||||
docker compose -f docker-compose.local.yaml ps postgres
|
||||
|
||||
# Check logs
|
||||
docker compose -f docker-compose.local.yaml logs postgres
|
||||
|
||||
# Restart PostgreSQL
|
||||
docker compose -f docker-compose.local.yaml restart postgres
|
||||
```
|
||||
|
||||
### Clear All Data and Start Fresh
|
||||
|
||||
```bash
|
||||
# Stop and remove everything
|
||||
docker compose -f docker-compose.local.yaml down -v
|
||||
|
||||
# Remove images
|
||||
docker compose -f docker-compose.local.yaml down --rmi all
|
||||
|
||||
# Start fresh
|
||||
docker compose -f docker-compose.local.yaml up -d --build
|
||||
|
||||
# Re-initialize
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan key:generate
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan migrate:fresh --seed
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### Windows Performance
|
||||
|
||||
If using WSL2 (recommended):
|
||||
|
||||
1. Clone repo inside WSL2 filesystem, not Windows filesystem
|
||||
2. Use WSL2 terminal for commands
|
||||
3. Enable WSL2 integration in Docker Desktop settings
|
||||
|
||||
### Mac Performance
|
||||
|
||||
1. Enable VirtioFS in Docker Desktop settings
|
||||
2. Disable file watching if not needed
|
||||
3. Use Docker volumes for vendor directories:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
- /var/www/vendor # Anonymous volume for vendor
|
||||
- /var/www/node_modules # Anonymous volume for node_modules
|
||||
```
|
||||
|
||||
## Testing Production-Like Setup
|
||||
|
||||
To test the production VPN setup locally (advanced):
|
||||
|
||||
1. Enable WireGuard in `docker-compose.yaml.example`
|
||||
2. Change all `10.13.13.1` bindings to `127.0.0.1`
|
||||
3. Test SSL with self-signed certificates
|
||||
|
||||
## Differences from Production
|
||||
|
||||
| Feature | Local | Production |
|
||||
|---------|-------|------------|
|
||||
| **VPN** | No VPN | WireGuard required |
|
||||
| **Port** | :8080 | :80/:443 |
|
||||
| **SSL** | No SSL | Let's Encrypt |
|
||||
| **Debug** | Enabled | Disabled |
|
||||
| **Emails** | Mailpit | Real SMTP |
|
||||
| **Logs** | Debug level | Error level |
|
||||
| **Code** | Live mount | Built into image |
|
||||
|
||||
## Next Steps
|
||||
|
||||
After testing locally:
|
||||
|
||||
1. Review `docker-compose.yaml.example` for production
|
||||
2. Follow `DEPLOYMENT_GUIDE.md` for VPS setup
|
||||
3. Configure WireGuard VPN
|
||||
4. Deploy to production
|
||||
|
||||
## Useful Resources
|
||||
|
||||
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||
- [Laravel Docker Documentation](https://laravel.com/docs/deployment)
|
||||
- [PostgreSQL Docker](https://hub.docker.com/_/postgres)
|
||||
- [Mailpit Documentation](https://github.com/axllent/mailpit)
|
||||
@@ -0,0 +1,159 @@
|
||||
# Quick Start: VPN-Only Access Setup
|
||||
|
||||
⚠️ **IMPORTANT:** This application is configured for VPN-ONLY access. It will NOT be publicly accessible.
|
||||
|
||||
## Quick Setup Steps
|
||||
|
||||
### 1. Install Docker (on VPS)
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
### 2. Clone & Configure
|
||||
```bash
|
||||
git clone YOUR_GITEA_REPO/Teren-app.git
|
||||
cd Teren-app
|
||||
cp docker-compose.yaml.example docker-compose.yaml
|
||||
cp .env.production.example .env
|
||||
```
|
||||
|
||||
### 3. Edit Configuration
|
||||
```bash
|
||||
vim .env
|
||||
```
|
||||
|
||||
**Required changes:**
|
||||
- `WG_SERVERURL` = Your VPS public IP (e.g., `123.45.67.89`)
|
||||
- `WG_UI_PASSWORD` = Strong password for WireGuard dashboard
|
||||
- `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` = Database credentials
|
||||
- `PGADMIN_EMAIL`, `PGADMIN_PASSWORD` = pgAdmin credentials
|
||||
|
||||
### 4. Start WireGuard First
|
||||
```bash
|
||||
# Enable kernel module
|
||||
sudo modprobe wireguard
|
||||
|
||||
# Start WireGuard
|
||||
docker compose up -d wireguard
|
||||
|
||||
# Wait 10 seconds
|
||||
sleep 10
|
||||
|
||||
# Check status
|
||||
docker compose logs wireguard
|
||||
```
|
||||
|
||||
### 5. Setup VPN Client (on your laptop/desktop)
|
||||
|
||||
**Access WireGuard Dashboard:** `http://YOUR_VPS_IP:51821`
|
||||
|
||||
1. Login with password from step 3
|
||||
2. Click "New Client"
|
||||
3. Name it (e.g., "MyLaptop")
|
||||
4. Download config or scan QR code
|
||||
|
||||
**Install WireGuard Client:**
|
||||
- Windows: https://www.wireguard.com/install/
|
||||
- macOS: App Store
|
||||
- Linux: `sudo apt install wireguard`
|
||||
- Mobile: App Store / Play Store
|
||||
|
||||
**Import config and CONNECT**
|
||||
|
||||
### 6. Verify VPN Works
|
||||
```bash
|
||||
# From your local machine (while connected to VPN)
|
||||
ping 10.13.13.1
|
||||
```
|
||||
|
||||
Should get responses ✅
|
||||
|
||||
### 7. Secure WireGuard Dashboard
|
||||
|
||||
Edit `docker-compose.yaml`:
|
||||
```yaml
|
||||
# Find wireguard service, change:
|
||||
ports:
|
||||
- "51821:51821/tcp"
|
||||
# To:
|
||||
ports:
|
||||
- "10.13.13.1:51821:51821/tcp"
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d wireguard
|
||||
```
|
||||
|
||||
### 8. Start All Services
|
||||
```bash
|
||||
# Make sure you're connected to VPN!
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 9. Initialize Application
|
||||
```bash
|
||||
# Generate app key
|
||||
docker compose exec app php artisan key:generate
|
||||
|
||||
# Run migrations
|
||||
docker compose exec app php artisan migrate --force
|
||||
|
||||
# Cache config
|
||||
docker compose exec app php artisan config:cache
|
||||
```
|
||||
|
||||
### 10. Access Your Services
|
||||
|
||||
**While connected to VPN:**
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| **Laravel App** | http://10.13.13.1 |
|
||||
| **Portainer** | http://10.13.13.1:9000 |
|
||||
| **pgAdmin** | http://10.13.13.1:5050 |
|
||||
| **WireGuard UI** | http://10.13.13.1:51821 |
|
||||
|
||||
## Firewall Configuration
|
||||
|
||||
```bash
|
||||
sudo ufw allow 22/tcp # SSH
|
||||
sudo ufw allow 51820/udp # WireGuard VPN
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
**That's it!** ✅
|
||||
|
||||
## Adding More VPN Clients
|
||||
|
||||
1. Connect to VPN
|
||||
2. Open: `http://10.13.13.1:51821`
|
||||
3. Click "New Client"
|
||||
4. Download config
|
||||
5. Import on new device
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Can't connect to VPN:**
|
||||
```bash
|
||||
docker compose logs wireguard
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
**Can't access app after VPN connection:**
|
||||
```bash
|
||||
ping 10.13.13.1
|
||||
docker compose ps
|
||||
docker compose logs nginx
|
||||
```
|
||||
|
||||
**Check which ports are exposed:**
|
||||
```bash
|
||||
docker compose ps
|
||||
sudo netstat -tulpn | grep 10.13.13.1
|
||||
```
|
||||
|
||||
## Full Documentation
|
||||
|
||||
See `DEPLOYMENT_GUIDE.md` for complete setup instructions, SSL configuration, automated deployments, and troubleshooting.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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.
|
||||
@@ -24,8 +24,7 @@ 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();
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Contract;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use Maatwebsite\Excel\Concerns\WithCustomValueBinder;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
|
||||
class ClientContractsExport extends DefaultValueBinder implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithCustomValueBinder, WithHeadings, WithMapping
|
||||
{
|
||||
public const DATE_EXCEL_FORMAT = 'dd"."mm"."yyyy';
|
||||
|
||||
public const TEXT_EXCEL_FORMAT = NumberFormat::FORMAT_TEXT;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $columnLetterMap = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array{label: string}>
|
||||
*/
|
||||
public const COLUMN_METADATA = [
|
||||
'reference' => ['label' => 'Referenca'],
|
||||
'customer' => ['label' => 'Stranka'],
|
||||
'address' => ['label' => 'Naslov'],
|
||||
'start' => ['label' => 'Začetek'],
|
||||
'segment' => ['label' => 'Segment'],
|
||||
'balance' => ['label' => 'Stanje'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<int, string> $columns
|
||||
*/
|
||||
public function __construct(private Builder $query, private array $columns) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function allowedColumns(): array
|
||||
{
|
||||
return array_keys(self::COLUMN_METADATA);
|
||||
}
|
||||
|
||||
public static function columnLabel(string $column): string
|
||||
{
|
||||
return self::COLUMN_METADATA[$column]['label'] ?? $column;
|
||||
}
|
||||
|
||||
public function query(): Builder
|
||||
{
|
||||
return $this->query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public function map($row): array
|
||||
{
|
||||
return array_map(fn (string $column) => $this->resolveValue($row, $column), $this->columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function headings(): array
|
||||
{
|
||||
return array_map(fn (string $column) => self::columnLabel($column), $this->columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function columnFormats(): array
|
||||
{
|
||||
$formats = [];
|
||||
|
||||
foreach ($this->getColumnLetterMap() as $letter => $column) {
|
||||
if ($column === 'reference') {
|
||||
$formats[$letter] = self::TEXT_EXCEL_FORMAT;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($column === 'start') {
|
||||
$formats[$letter] = self::DATE_EXCEL_FORMAT;
|
||||
}
|
||||
}
|
||||
|
||||
return $formats;
|
||||
}
|
||||
|
||||
private function resolveValue(Contract $contract, string $column): mixed
|
||||
{
|
||||
return match ($column) {
|
||||
'reference' => $contract->reference,
|
||||
'customer' => optional($contract->clientCase?->person)->full_name,
|
||||
'address' => optional($contract->clientCase?->person?->address)->address,
|
||||
'start' => $this->formatDate($contract->start_date),
|
||||
'segment' => $contract->segments?->first()?->name,
|
||||
'balance' => optional($contract->account)->balance_amount,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function formatDate(?string $date): mixed
|
||||
{
|
||||
if (empty($date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$carbon = Carbon::parse($date);
|
||||
|
||||
return ExcelDate::dateTimeToExcel($carbon);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getColumnLetterMap(): array
|
||||
{
|
||||
if ($this->columnLetterMap !== []) {
|
||||
return $this->columnLetterMap;
|
||||
}
|
||||
|
||||
$letter = 'A';
|
||||
foreach ($this->columns as $column) {
|
||||
$this->columnLetterMap[$letter] = $column;
|
||||
$letter++;
|
||||
}
|
||||
|
||||
return $this->columnLetterMap;
|
||||
}
|
||||
|
||||
public function bindValue(Cell $cell, $value): bool
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
$cell->setValueExplicit($value, DataType::TYPE_NUMERIC);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return parent::bindValue($cell, $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
{
|
||||
//
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreInstallmentRequest;
|
||||
use App\Models\Account;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Booking;
|
||||
use App\Models\Installment;
|
||||
use App\Models\InstallmentSetting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class AccountInstallmentController extends Controller
|
||||
{
|
||||
public function list(Account $account): JsonResponse
|
||||
{
|
||||
$installments = Installment::query()
|
||||
->where('account_id', $account->id)
|
||||
->orderByDesc('installment_at')
|
||||
->get(['id', 'amount', 'balance_before', 'currency', 'reference', 'installment_at', 'created_at'])
|
||||
->map(function (Installment $i) {
|
||||
return [
|
||||
'id' => $i->id,
|
||||
'amount' => (float) $i->amount,
|
||||
'balance_before' => (float) ($i->balance_before ?? 0),
|
||||
'currency' => $i->currency,
|
||||
'reference' => $i->reference,
|
||||
'installment_at' => optional($i->installment_at)?->toDateString(),
|
||||
'created_at' => optional($i->created_at)?->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'account' => [
|
||||
'id' => $account->id,
|
||||
'balance_amount' => $account->balance_amount,
|
||||
],
|
||||
'installments' => $installments,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreInstallmentRequest $request, Account $account): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$amountCents = (int) round(((float) $validated['amount']) * 100);
|
||||
|
||||
$settings = InstallmentSetting::query()->first();
|
||||
$defaultCurrency = strtoupper($settings->default_currency ?? 'EUR');
|
||||
|
||||
$installment = Installment::query()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_before' => (float) ($account->balance_amount ?? 0),
|
||||
'amount' => (float) $validated['amount'],
|
||||
'currency' => strtoupper($validated['currency'] ?? $defaultCurrency),
|
||||
'reference' => $validated['reference'] ?? null,
|
||||
'installment_at' => $validated['installment_at'] ?? now(),
|
||||
'meta' => $validated['meta'] ?? null,
|
||||
'created_by' => $request->user()?->id,
|
||||
]);
|
||||
|
||||
// Debit booking — increases the account balance
|
||||
Booking::query()->create([
|
||||
'account_id' => $account->id,
|
||||
'payment_id' => null,
|
||||
'amount_cents' => $amountCents,
|
||||
'type' => 'debit',
|
||||
'description' => $installment->reference ? ('Obremenitev '.$installment->reference) : 'Obremenitev',
|
||||
'booked_at' => $installment->installment_at ?? now(),
|
||||
]);
|
||||
|
||||
if ($settings && ($settings->create_activity_on_installment ?? false)) {
|
||||
$note = $settings->activity_note_template ?? 'Dodan obrok';
|
||||
$note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $installment->currency], $note);
|
||||
|
||||
$account->refresh();
|
||||
$beforeStr = number_format((float) ($installment->balance_before ?? 0), 2, ',', '.').' '.$installment->currency;
|
||||
$afterStr = number_format((float) ($account->balance_amount ?? 0), 2, ',', '.').' '.$installment->currency;
|
||||
$note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: obrok)";
|
||||
|
||||
$account->loadMissing('contract');
|
||||
$clientCaseId = $account->contract?->client_case_id;
|
||||
if ($clientCaseId) {
|
||||
$activity = Activity::query()->create([
|
||||
'due_date' => null,
|
||||
'amount' => $amountCents / 100,
|
||||
'note' => $note,
|
||||
'action_id' => $settings->default_action_id,
|
||||
'decision_id' => $settings->default_decision_id,
|
||||
'client_case_id' => $clientCaseId,
|
||||
'contract_id' => $account->contract_id,
|
||||
]);
|
||||
$installment->update(['activity_id' => $activity->id]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'Installment created.');
|
||||
}
|
||||
|
||||
public function destroy(Account $account, Installment $installment): RedirectResponse|JsonResponse
|
||||
{
|
||||
if ($installment->account_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Delete related debit booking(s) to revert balance via model events
|
||||
Booking::query()
|
||||
->where('account_id', $account->id)
|
||||
->where('type', 'debit')
|
||||
->whereDate('booked_at', optional($installment->installment_at)?->toDateString())
|
||||
->where('amount_cents', (int) round(((float) $installment->amount) * 100))
|
||||
->whereNull('payment_id')
|
||||
->get()
|
||||
->each->delete();
|
||||
|
||||
if ($installment->activity_id) {
|
||||
$activity = Activity::query()->find($installment->activity_id);
|
||||
if ($activity) {
|
||||
$activity->delete();
|
||||
}
|
||||
}
|
||||
|
||||
$installment->delete();
|
||||
|
||||
if (request()->wantsJson()) {
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Installment deleted.');
|
||||
}
|
||||
}
|
||||
@@ -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,9 +24,18 @@ 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);
|
||||
|
||||
$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,
|
||||
@@ -35,7 +46,8 @@ public function __invoke(Request $request)
|
||||
'read_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
return back();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailTemplate;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -69,4 +70,15 @@ public function show(EmailLog $emailLog): Response
|
||||
'log' => $emailLog,
|
||||
]);
|
||||
}
|
||||
|
||||
public function body(EmailLog $emailLog): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', EmailTemplate::class);
|
||||
|
||||
$emailLog->load('body');
|
||||
|
||||
return response()->json([
|
||||
'html' => $emailLog->body?->body_html ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailLogStatus;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\MailProfile;
|
||||
use App\Services\EmailTemplateRenderer;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -55,8 +56,14 @@ public function create(): Response
|
||||
{
|
||||
$this->authorize('create', EmailTemplate::class);
|
||||
|
||||
$actions = \App\Models\Action::query()
|
||||
->with(['decisions:id,name'])
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
return Inertia::render('Admin/EmailTemplates/Edit', [
|
||||
'template' => null,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -93,7 +100,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
|
||||
// Context resolution (shared logic with renderFinalHtml)
|
||||
$ctx = [];
|
||||
if ($id = $request->integer('activity_id')) {
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
|
||||
if ($activity) {
|
||||
$ctx['activity'] = $activity;
|
||||
// Derive base entities from activity when not explicitly provided
|
||||
@@ -110,7 +117,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
|
||||
}
|
||||
}
|
||||
if ($id = $request->integer('contract_id')) {
|
||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
|
||||
if ($contract) {
|
||||
$ctx['contract'] = $contract;
|
||||
if ($contract->clientCase) {
|
||||
@@ -140,6 +147,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
|
||||
}
|
||||
}
|
||||
$ctx['extra'] = (array) $request->input('extra', []);
|
||||
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
|
||||
|
||||
$rendered = $renderer->render([
|
||||
'subject' => $subject,
|
||||
@@ -161,8 +169,14 @@ public function edit(EmailTemplate $emailTemplate): Response
|
||||
$q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']);
|
||||
}]);
|
||||
|
||||
$actions = \App\Models\Action::query()
|
||||
->with(['decisions:id,name'])
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
return Inertia::render('Admin/EmailTemplates/Edit', [
|
||||
'template' => $emailTemplate,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -181,7 +195,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
||||
// Context resolution
|
||||
$ctx = [];
|
||||
if ($id = $request->integer('activity_id')) {
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
|
||||
if ($activity) {
|
||||
$ctx['activity'] = $activity;
|
||||
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||
@@ -197,7 +211,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
||||
}
|
||||
}
|
||||
if ($id = $request->integer('contract_id')) {
|
||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
|
||||
if ($contract) {
|
||||
$ctx['contract'] = $contract;
|
||||
if ($contract->clientCase) {
|
||||
@@ -227,6 +241,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
||||
}
|
||||
}
|
||||
$ctx['extra'] = (array) $request->input('extra', []);
|
||||
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
|
||||
|
||||
// Render preview values; we store a minimal snapshot on the log
|
||||
$rendered = $renderer->render([
|
||||
@@ -293,7 +308,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
||||
// Context resolution (same as sendTest)
|
||||
$ctx = [];
|
||||
if ($id = $request->integer('activity_id')) {
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
|
||||
if ($activity) {
|
||||
$ctx['activity'] = $activity;
|
||||
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||
@@ -309,7 +324,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
||||
}
|
||||
}
|
||||
if ($id = $request->integer('contract_id')) {
|
||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
|
||||
if ($contract) {
|
||||
$ctx['contract'] = $contract;
|
||||
if ($contract->clientCase) {
|
||||
@@ -339,6 +354,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
||||
}
|
||||
}
|
||||
$ctx['extra'] = (array) $request->input('extra', []);
|
||||
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
|
||||
|
||||
$rendered = $renderer->render([
|
||||
'subject' => $subject,
|
||||
|
||||
@@ -26,7 +26,7 @@ public function index(): Response
|
||||
->orderBy('priority')
|
||||
->orderBy('id')
|
||||
->get([
|
||||
'id', 'name', 'active', 'host', 'port', 'encryption', 'from_address', 'priority', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at',
|
||||
'id', 'name', 'active', 'auto_mailer', 'host', 'port', 'username', 'from_name', 'encryption', 'from_address', 'priority', 'signature', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at',
|
||||
]);
|
||||
|
||||
return Inertia::render('Admin/MailProfiles/Index', [
|
||||
@@ -76,6 +76,15 @@ public function toggle(Request $request, MailProfile $mailProfile)
|
||||
return back()->with('success', 'Status updated');
|
||||
}
|
||||
|
||||
public function toggleAutoMailer(Request $request, MailProfile $mailProfile)
|
||||
{
|
||||
$this->authorize('update', $mailProfile);
|
||||
$mailProfile->auto_mailer = ! $mailProfile->auto_mailer;
|
||||
$mailProfile->save();
|
||||
|
||||
return back()->with('success', 'Auto-mailer updated');
|
||||
}
|
||||
|
||||
public function test(Request $request, MailProfile $mailProfile)
|
||||
{
|
||||
$this->authorize('test', $mailProfile);
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreEmailPackageFromContractsRequest;
|
||||
use App\Http\Requests\StorePackageFromContractsRequest;
|
||||
use App\Http\Requests\StorePackageRequest;
|
||||
use App\Jobs\PackageItemEmailJob;
|
||||
use App\Jobs\PackageItemSmsJob;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackageItem;
|
||||
use App\Models\SmsTemplate;
|
||||
use App\Services\Contact\EmailSelector;
|
||||
use App\Services\Contact\PhoneSelector;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -21,11 +24,41 @@
|
||||
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
public function landing(): Response
|
||||
{
|
||||
return Inertia::render('Packages/Index');
|
||||
}
|
||||
|
||||
public function smsIndex(Request $request): Response
|
||||
{
|
||||
$perPage = $request->input('per_page') ?? 25;
|
||||
|
||||
$packages = Package::query()
|
||||
->where('type', Package::TYPE_SMS)
|
||||
->latest('id')
|
||||
->paginate(20);
|
||||
->paginate($perPage);
|
||||
|
||||
return Inertia::render('Packages/Sms/Index', [
|
||||
'packages' => $packages,
|
||||
]);
|
||||
}
|
||||
|
||||
public function emailIndex(Request $request): Response
|
||||
{
|
||||
$perPage = $request->input('per_page') ?? 25;
|
||||
|
||||
$packages = Package::query()
|
||||
->where('type', Package::TYPE_EMAIL)
|
||||
->latest('id')
|
||||
->paginate($perPage);
|
||||
|
||||
return Inertia::render('Packages/Mail/Index', [
|
||||
'packages' => $packages,
|
||||
]);
|
||||
}
|
||||
|
||||
public function smsCreate(Request $request): Response
|
||||
{
|
||||
// Minimal lookups for create form (active only)
|
||||
$profiles = \App\Models\SmsProfile::query()
|
||||
->where('active', true)
|
||||
@@ -37,9 +70,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 +92,7 @@ public function index(Request $request): Response
|
||||
})
|
||||
->values();
|
||||
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
'packages' => $packages,
|
||||
return Inertia::render('Packages/Sms/Create', [
|
||||
'profiles' => $profiles,
|
||||
'senders' => $senders,
|
||||
'templates' => $templates,
|
||||
@@ -68,7 +101,53 @@ public function index(Request $request): Response
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Package $package, SmsService $sms): Response
|
||||
public function emailCreate(): Response
|
||||
{
|
||||
$emailTemplates = \App\Models\EmailTemplate::query()
|
||||
->where('active', true)
|
||||
->where('client', false)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'subject_template', 'text_template', 'html_template'])
|
||||
->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'name' => $t->name,
|
||||
'subject_template' => $t->subject_template,
|
||||
'text_template' => $t->text_template,
|
||||
'has_body_text' => (bool) preg_match('/{{\s*body_text\s*}}/', (string) $t->html_template),
|
||||
])->values();
|
||||
$mailProfiles = \App\Models\MailProfile::query()
|
||||
->where('active', true)
|
||||
->orderBy('priority')
|
||||
->get(['id', 'name']);
|
||||
$segments = \App\Models\Segment::query()
|
||||
->where('active', true)
|
||||
->where('exclude', false)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
$clients = \App\Models\Client::query()
|
||||
->with(['person' => function ($q) {
|
||||
$q->select('id', 'uuid', 'full_name');
|
||||
}])
|
||||
->latest('id')
|
||||
->get(['id', 'uuid', 'person_id'])
|
||||
->map(function ($c) {
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'uuid' => $c->uuid,
|
||||
'name' => $c->person?->full_name ?? ('Client #'.$c->id),
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
return Inertia::render('Packages/Mail/Create', [
|
||||
'emailTemplates' => $emailTemplates,
|
||||
'mailProfiles' => $mailProfiles,
|
||||
'segments' => $segments,
|
||||
'clients' => $clients,
|
||||
]);
|
||||
}
|
||||
|
||||
public function smsShow(Package $package, SmsService $sms): Response
|
||||
{
|
||||
$items = $package->items()->latest('id')->paginate(25);
|
||||
|
||||
@@ -98,6 +177,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 +204,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 +240,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 +262,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 */
|
||||
@@ -194,13 +281,23 @@ public function show(Package $package, SmsService $sms): Response
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('Admin/Packages/Show', [
|
||||
return Inertia::render('Packages/Sms/Show', [
|
||||
'package' => $package,
|
||||
'items' => $items,
|
||||
'preview' => $preview,
|
||||
]);
|
||||
}
|
||||
|
||||
public function emailShow(Package $package): Response
|
||||
{
|
||||
$items = $package->items()->latest('id')->paginate(25);
|
||||
|
||||
return Inertia::render('Packages/Mail/Show', [
|
||||
'package' => $package,
|
||||
'items' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StorePackageRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
@@ -215,6 +312,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([
|
||||
@@ -240,7 +339,11 @@ public function dispatch(Package $package): RedirectResponse
|
||||
return back()->with('error', 'Package not in a dispatchable state.');
|
||||
}
|
||||
|
||||
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) {
|
||||
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) use ($package) {
|
||||
if ($package->type === Package::TYPE_EMAIL) {
|
||||
return new PackageItemEmailJob($item->id);
|
||||
}
|
||||
|
||||
return new PackageItemSmsJob($item->id);
|
||||
})->all();
|
||||
|
||||
@@ -266,7 +369,7 @@ public function dispatch(Package $package): RedirectResponse
|
||||
$package->save();
|
||||
}
|
||||
})
|
||||
->onQueue('sms')
|
||||
->onQueue($package->type === Package::TYPE_EMAIL ? 'email' : 'sms')
|
||||
->dispatch();
|
||||
|
||||
return back()->with('success', 'Package dispatched');
|
||||
@@ -300,30 +403,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 +455,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 +491,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 +516,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
'uuid' => $person?->uuid,
|
||||
'full_name' => $person?->full_name,
|
||||
],
|
||||
'segment' => $segment,
|
||||
// Stranka: the client person
|
||||
'client' => $clientPerson ? [
|
||||
'id' => $contract->clientCase?->client?->id,
|
||||
@@ -380,6 +528,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
'number' => $phone->nu,
|
||||
'validated' => $phone->validated,
|
||||
'type' => $phone->phone_type?->value,
|
||||
'description' => $phone->description,
|
||||
] : null,
|
||||
'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'),
|
||||
];
|
||||
@@ -387,12 +536,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 +571,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 +624,254 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
|
||||
|
||||
return back()->with('success', 'Package created from contracts');
|
||||
}
|
||||
|
||||
/**
|
||||
* List contracts with selected email per person (for email packages).
|
||||
*/
|
||||
public function contractsForEmail(Request $request, EmailSelector $selector): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||
'q' => ['nullable', 'string'],
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
'only_verified' => ['nullable', 'boolean'],
|
||||
'only_with_email' => ['nullable', 'boolean'],
|
||||
'start_date_from' => ['nullable', 'date'],
|
||||
'start_date_to' => ['nullable', 'date'],
|
||||
'promise_date_from' => ['nullable', 'date'],
|
||||
'promise_date_to' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||
|
||||
$query = Contract::query()
|
||||
->with([
|
||||
'clientCase.person.emails',
|
||||
'clientCase.client.person',
|
||||
'account',
|
||||
'segments:id,name',
|
||||
])
|
||||
->select('contracts.*')
|
||||
->latest('contracts.id');
|
||||
|
||||
if ($segmentId) {
|
||||
$query->join('contract_segment', function ($j) use ($segmentId) {
|
||||
$j->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||
->where('contract_segment.segment_id', '=', $segmentId)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
} else {
|
||||
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
|
||||
->from('contract_segment')
|
||||
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
|
||||
->where('contract_segment.active', true)
|
||||
->where('segments.exclude', false)
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
);
|
||||
}
|
||||
|
||||
if ($q = trim((string) $request->input('q'))) {
|
||||
$query->where('contracts.reference', 'ILIKE', "%{$q}%");
|
||||
}
|
||||
|
||||
if ($clientId = $request->integer('client_id')) {
|
||||
$query->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
|
||||
->where('client_cases.client_id', $clientId);
|
||||
}
|
||||
|
||||
if ($startDateFrom = $request->input('start_date_from')) {
|
||||
$query->where('contracts.start_date', '>=', $startDateFrom);
|
||||
}
|
||||
|
||||
if ($startDateTo = $request->input('start_date_to')) {
|
||||
$query->where('contracts.start_date', '<=', $startDateTo);
|
||||
}
|
||||
|
||||
$promiseDateFrom = $request->input('promise_date_from');
|
||||
$promiseDateTo = $request->input('promise_date_to');
|
||||
|
||||
if ($promiseDateFrom || $promiseDateTo) {
|
||||
$query->whereHas('account', function ($q) use ($promiseDateFrom, $promiseDateTo) {
|
||||
if ($promiseDateFrom) {
|
||||
$q->where('promise_date', '>=', $promiseDateFrom);
|
||||
}
|
||||
if ($promiseDateTo) {
|
||||
$q->where('promise_date', '<=', $promiseDateTo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->boolean('only_verified')) {
|
||||
$query->whereHas('clientCase.person.emails', function ($q) {
|
||||
$q->where('is_active', true)->whereNotNull('verified_at');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->boolean('only_with_email')) {
|
||||
$query->whereHas('clientCase.person.emails', function ($q) {
|
||||
$q->where('is_active', true);
|
||||
});
|
||||
}
|
||||
|
||||
$contracts = $query->limit(500)->get();
|
||||
|
||||
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
|
||||
$person = $contract->clientCase?->person;
|
||||
$selected = $person ? $selector->selectForPerson($person) : ['email' => null, 'reason' => 'no_person'];
|
||||
$email = $selected['email'];
|
||||
$clientPerson = $contract->clientCase?->client?->person;
|
||||
$segment = collect($contract->segments)->last();
|
||||
|
||||
return [
|
||||
'id' => $contract->id,
|
||||
'uuid' => $contract->uuid,
|
||||
'reference' => $contract->reference,
|
||||
'start_date' => $contract->start_date,
|
||||
'promise_date' => $contract->account?->promise_date,
|
||||
'case' => [
|
||||
'id' => $contract->clientCase?->id,
|
||||
'uuid' => $contract->clientCase?->uuid,
|
||||
],
|
||||
'person' => [
|
||||
'id' => $person?->id,
|
||||
'uuid' => $person?->uuid,
|
||||
'full_name' => $person?->full_name,
|
||||
],
|
||||
'segment' => $segment,
|
||||
'client' => $clientPerson ? [
|
||||
'id' => $contract->clientCase?->client?->id,
|
||||
'uuid' => $contract->clientCase?->client?->uuid,
|
||||
'name' => $clientPerson->full_name,
|
||||
] : null,
|
||||
'selected_email' => $email ? [
|
||||
'id' => $email->id,
|
||||
'value' => $email->value,
|
||||
'is_primary' => $email->is_primary,
|
||||
'verified' => $email->verified_at !== null,
|
||||
'label' => $email->label,
|
||||
] : null,
|
||||
'no_email_reason' => $email ? null : ($selected['reason'] ?? 'unknown'),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an email package from a list of contracts by selecting recipient emails.
|
||||
*/
|
||||
public function storeEmailFromContracts(StoreEmailPackageFromContractsRequest $request, EmailSelector $selector): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
$contracts = Contract::query()
|
||||
->with(['clientCase.person', 'account.type'])
|
||||
->whereIn('id', $data['contract_ids'])
|
||||
->get();
|
||||
|
||||
$items = [];
|
||||
$skipped = 0;
|
||||
foreach ($contracts as $contract) {
|
||||
$person = $contract->clientCase?->person;
|
||||
if (! $person) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$selected = $selector->selectForPerson($person);
|
||||
/** @var ?\App\Models\Email $email */
|
||||
$email = $selected['email'];
|
||||
if (! $email) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'email' => $email->value,
|
||||
'email_id' => $email->id,
|
||||
'payload' => $data['payload'] ?? [],
|
||||
'contract_id' => $contract->id,
|
||||
'account_id' => $contract->account?->id,
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($items)) {
|
||||
return back()->with('error', 'No recipients found for selected contracts.');
|
||||
}
|
||||
|
||||
$package = Package::query()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'type' => Package::TYPE_EMAIL,
|
||||
'status' => Package::STATUS_DRAFT,
|
||||
'name' => $data['name'] ?? null,
|
||||
'description' => $data['description'] ?? null,
|
||||
'meta' => array_merge($data['meta'] ?? [], [
|
||||
'source' => 'contracts',
|
||||
'skipped' => $skipped,
|
||||
]),
|
||||
'created_by' => optional($request->user())->id,
|
||||
]);
|
||||
|
||||
$packageItems = collect($items)->map(function (array $row) {
|
||||
return new PackageItem([
|
||||
'status' => 'queued',
|
||||
'target_json' => [
|
||||
'email' => $row['email'],
|
||||
'email_id' => $row['email_id'],
|
||||
'contract_id' => $row['contract_id'] ?? null,
|
||||
'account_id' => $row['account_id'] ?? null,
|
||||
],
|
||||
'payload_json' => $row['payload'] ?? [],
|
||||
]);
|
||||
});
|
||||
|
||||
$package->items()->saveMany($packageItems);
|
||||
$package->total_items = $packageItems->count();
|
||||
$package->save();
|
||||
|
||||
return back()->with('success', 'Email package created from contracts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten nested meta structure into dot-notation key-value pairs.
|
||||
* Extracts 'value' from objects with {title, value, type} structure.
|
||||
* 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', 'login_redirect']);
|
||||
$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,29 @@ 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}");
|
||||
}
|
||||
|
||||
public function updateSettings(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
Gate::authorize('manage-settings');
|
||||
|
||||
$validated = $request->validate([
|
||||
'login_redirect' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$user->update($validated);
|
||||
|
||||
return back()->with('success', 'Nastavitve shranjene');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CallLater;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class CallLaterController extends Controller
|
||||
{
|
||||
public function index(Request $request): \Inertia\Response
|
||||
{
|
||||
$query = CallLater::query()
|
||||
->with([
|
||||
'clientCase.person',
|
||||
'contract',
|
||||
'user',
|
||||
'activity',
|
||||
])
|
||||
->whereNull('completed_at')
|
||||
->orderBy('call_back_at', 'asc');
|
||||
|
||||
if ($request->filled('date_from')) {
|
||||
$query->whereDate('call_back_at', '>=', $request->date_from);
|
||||
}
|
||||
if ($request->filled('date_to')) {
|
||||
$query->whereDate('call_back_at', '<=', $request->date_to);
|
||||
}
|
||||
if ($request->filled('search')) {
|
||||
$term = '%'.$request->search.'%';
|
||||
$query->whereHas('clientCase.person', function ($q) use ($term) {
|
||||
$q->where('first_name', 'ilike', $term)
|
||||
->orWhere('last_name', 'ilike', $term)
|
||||
->orWhere('full_name', 'ilike', $term)
|
||||
->orWhereRaw("CONCAT(first_name, ' ', last_name) ILIKE ?", [$term]);
|
||||
});
|
||||
}
|
||||
|
||||
$callLaters = $query->paginate(50)->withQueryString();
|
||||
|
||||
return Inertia::render('CallLaters/Index', [
|
||||
'callLaters' => $callLaters,
|
||||
'filters' => $request->only(['date_from', 'date_to', 'search']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function complete(CallLater $callLater): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$callLater->update(['completed_at' => now()]);
|
||||
|
||||
return back()->with('success', 'Klic označen kot opravljen.');
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
use App\Models\CaseObject;
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CaseObjectController extends Controller
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,15 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exports\ClientContractsExport;
|
||||
use App\Http\Requests\ExportClientContractsRequest;
|
||||
use App\Models\Client;
|
||||
use App\Services\ReferenceDataCache;
|
||||
use DB;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
@@ -23,7 +27,7 @@ public function index(Client $client, Request $request)
|
||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||
->groupBy('clients.id');
|
||||
})
|
||||
->where('clients.active', 1)
|
||||
// ->where('clients.active', 1)
|
||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
||||
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
||||
->leftJoin('contracts', function ($join) {
|
||||
@@ -36,18 +40,14 @@ public function index(Client $client, Request $request)
|
||||
})
|
||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->groupBy('clients.id')
|
||||
->addSelect([
|
||||
// Number of client cases for this client that have at least one active contract
|
||||
DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count'),
|
||||
// Sum of account balances for active contracts
|
||||
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
])
|
||||
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count')
|
||||
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
|
||||
->with('person')
|
||||
->orderByDesc('clients.created_at');
|
||||
|
||||
return Inertia::render('Client/Index', [
|
||||
'clients' => $query
|
||||
->paginate($request->integer('per_page', 15))
|
||||
->paginate($request->integer('per_page', default: 100))
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
@@ -67,6 +67,7 @@ public function show(Client $client, Request $request)
|
||||
|
||||
return Inertia::render('Client/Show', [
|
||||
'client' => $data,
|
||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||
'client_cases' => $data->clientCases()
|
||||
->select('client_cases.*')
|
||||
->when($request->input('search'), function ($que, $search) {
|
||||
@@ -84,10 +85,8 @@ public function show(Client $client, Request $request)
|
||||
})
|
||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->groupBy('client_cases.id')
|
||||
->addSelect([
|
||||
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
|
||||
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
])
|
||||
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
|
||||
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
|
||||
->with(['person', 'client.person'])
|
||||
->where('client_cases.active', 1)
|
||||
->orderByDesc('client_cases.created_at')
|
||||
@@ -105,7 +104,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 +127,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 +151,100 @@ 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(),
|
||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||
'contracts' => $contractsQuery
|
||||
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['from', 'to', 'search', 'segment']),
|
||||
'segments' => $segments,
|
||||
'types' => $types,
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportContracts(ExportClientContractsRequest $request, Client $client)
|
||||
{
|
||||
$data = $request->validated();
|
||||
$columns = array_values(array_unique($data['columns']));
|
||||
|
||||
$from = $data['from'] ?? null;
|
||||
$to = $data['to'] ?? null;
|
||||
$search = $data['search'] ?? null;
|
||||
$segmentsParam = $data['segments'] ?? null;
|
||||
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
|
||||
|
||||
$query = \App\Models\Contract::query()
|
||||
->whereHas('clientCase', function ($q) use ($client) {
|
||||
$q->where('client_id', $client->id);
|
||||
})
|
||||
->with([
|
||||
'clientCase:id,uuid,person_id',
|
||||
'clientCase.person:id,full_name',
|
||||
'clientCase.person.address',
|
||||
'segments' => function ($q) {
|
||||
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
||||
},
|
||||
'account:id,accounts.contract_id,balance_amount',
|
||||
])
|
||||
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
|
||||
->whereNull('deleted_at')
|
||||
->when($from || $to, function ($q) use ($from, $to) {
|
||||
if (! empty($from)) {
|
||||
$q->whereDate('start_date', '>=', $from);
|
||||
}
|
||||
if (! empty($to)) {
|
||||
$q->whereDate('start_date', '<=', $to);
|
||||
}
|
||||
})
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->where(function ($inner) use ($search) {
|
||||
$inner->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
||||
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
});
|
||||
})
|
||||
->when($segmentIds, function ($q) use ($segmentIds) {
|
||||
$q->whereHas('segments', function ($s) use ($segmentIds) {
|
||||
$s->whereIn('segments.id', $segmentIds)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
})
|
||||
->orderByDesc('start_date');
|
||||
|
||||
if (($data['scope'] ?? ExportClientContractsRequest::SCOPE_ALL) === ExportClientContractsRequest::SCOPE_CURRENT) {
|
||||
$page = max(1, (int) ($data['page'] ?? 1));
|
||||
$perPage = max(1, min(200, (int) ($data['per_page'] ?? 15)));
|
||||
$query->forPage($page, $perPage);
|
||||
}
|
||||
|
||||
$filename = $this->buildExportFilename($client);
|
||||
|
||||
return Excel::download(new ClientContractsExport($query, $columns), $filename);
|
||||
}
|
||||
|
||||
private function buildExportFilename(Client $client): string
|
||||
{
|
||||
$datePrefix = now()->format('dmy');
|
||||
$clientName = $this->slugify($client->person?->full_name ?? 'stranka');
|
||||
|
||||
return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName);
|
||||
}
|
||||
|
||||
private function slugify(?string $value): string
|
||||
{
|
||||
if (empty($value)) {
|
||||
return 'data';
|
||||
}
|
||||
|
||||
return Str::slug($value, '-') ?: 'data';
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ public function update(ContractConfig $config, Request $request)
|
||||
public function destroy(ContractConfig $config)
|
||||
{
|
||||
$config->delete();
|
||||
|
||||
return back()->with('success', 'Configuration deleted');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -41,7 +43,7 @@ public function store(Request $request)
|
||||
$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.'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
class ContractSettingController extends Controller
|
||||
{
|
||||
public function edit(): \Inertia\Response
|
||||
{
|
||||
$setting = \App\Models\ContractSetting::query()->first();
|
||||
if (! $setting) {
|
||||
$setting = \App\Models\ContractSetting::query()->create([
|
||||
'create_activity_on_balance_change' => false,
|
||||
'default_action_id' => null,
|
||||
'default_decision_id' => null,
|
||||
'activity_note_template' => 'Sprememba stanja pogodbe: {old_balance} → {new_balance} {currency}',
|
||||
]);
|
||||
}
|
||||
|
||||
$decisions = \App\Models\Decision::query()->orderBy('name')->get(['id', 'name']);
|
||||
$actions = \App\Models\Action::query()
|
||||
->with(['decisions:id'])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (\App\Models\Action $a) {
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'decision_ids' => $a->decisions->pluck('id')->values(),
|
||||
];
|
||||
});
|
||||
|
||||
return \Inertia\Inertia::render('Settings/Contracts/Index', [
|
||||
'setting' => [
|
||||
'id' => $setting->id,
|
||||
'create_activity_on_balance_change' => (bool) $setting->create_activity_on_balance_change,
|
||||
'default_action_id' => $setting->default_action_id,
|
||||
'default_decision_id' => $setting->default_decision_id,
|
||||
'activity_note_template' => $setting->activity_note_template,
|
||||
],
|
||||
'decisions' => $decisions,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(\App\Http\Requests\UpdateContractSettingRequest $request): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$setting = \App\Models\ContractSetting::query()->firstOrFail();
|
||||
|
||||
$data['create_activity_on_balance_change'] = (bool) ($data['create_activity_on_balance_change'] ?? false);
|
||||
|
||||
$setting->update($data);
|
||||
|
||||
return back()->with('success', 'Nastavitve shranjene.');
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,18 @@
|
||||
|
||||
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;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -21,25 +22,38 @@ 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()
|
||||
// Active contracts count - cached
|
||||
$activeContractsCount = Cache::remember('dashboard:active_contracts:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
||||
return Contract::whereNull('deleted_at')->count();
|
||||
});
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
// 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');
|
||||
})
|
||||
->whereNotNull('promise_date')
|
||||
->whereDate('promise_date', '>=', $today)
|
||||
->count();
|
||||
});
|
||||
|
||||
// Activities (limit 10) - cached
|
||||
$activities = Cache::remember('dashboard:activities', $cacheMinutes * 60, function () {
|
||||
return Activity::query()
|
||||
->with(['clientCase:id,uuid'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
@@ -54,180 +68,106 @@ public function __invoke(SmsService $sms): Response
|
||||
'action_id' => $a->action_id,
|
||||
'decision_id' => $a->decision_id,
|
||||
]);
|
||||
});
|
||||
|
||||
// 7-day trends (including today)
|
||||
// 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'));
|
||||
|
||||
$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')
|
||||
->selectRaw("DATE(COALESCE(assigned_at, created_at) AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
// Completed field jobs last 7 days
|
||||
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
||||
->whereBetween('completed_at', [$start, $end])
|
||||
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
|
||||
->selectRaw("DATE(completed_at AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
$trends = [
|
||||
'clients_new' => $dateKeys->map(fn ($d) => (int) ($clientTrendRaw[$d] ?? 0))->values(),
|
||||
'documents_new' => $dateKeys->map(fn ($d) => (int) ($documentTrendRaw[$d] ?? 0))->values(),
|
||||
return [
|
||||
'field_jobs' => $dateKeys->map(fn ($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(),
|
||||
'imports_new' => $dateKeys->map(fn ($d) => (int) ($importTrendRaw[$d] ?? 0))->values(),
|
||||
'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
|
||||
'labels' => $dateKeys,
|
||||
];
|
||||
|
||||
// Stale client cases (no activity in last 7 days)
|
||||
$staleCases = \App\Models\ClientCase::query()
|
||||
->leftJoin('activities', function ($join) {
|
||||
$join->on('activities.client_case_id', '=', 'client_cases.id')
|
||||
->whereNull('activities.deleted_at');
|
||||
})
|
||||
->selectRaw('client_cases.id, client_cases.uuid, client_cases.client_ref, MAX(activities.created_at) as last_activity_at, client_cases.created_at')
|
||||
->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);
|
||||
|
||||
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,
|
||||
];
|
||||
});
|
||||
|
||||
// Field jobs assigned today
|
||||
$fieldJobsAssignedToday = FieldJob::query()
|
||||
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
|
||||
// Field jobs assigned today - cached
|
||||
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
||||
return FieldJob::query()
|
||||
->whereRaw('DATE(COALESCE(assigned_at, created_at)) = ?', [$today->toDateString()])
|
||||
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
||||
->with(['contract' => function ($q) {
|
||||
$q->select('id', 'uuid', 'reference', 'client_case_id')
|
||||
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
|
||||
}])
|
||||
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
|
||||
->orderByRaw('COALESCE(assigned_at, created_at) DESC')
|
||||
->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;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 ? [
|
||||
'contract' => [
|
||||
'uuid' => $contract->uuid,
|
||||
'reference' => $contract->reference,
|
||||
'client_case_uuid' => optional($contract->clientCase)->uuid,
|
||||
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
|
||||
'segment_id' => $segmentId,
|
||||
] : null,
|
||||
],
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
});
|
||||
|
||||
// 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
|
||||
'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')
|
||||
@@ -249,13 +189,11 @@ public function __invoke(SmsService $sms): Response
|
||||
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) {
|
||||
@@ -271,6 +209,7 @@ public function __invoke(SmsService $sms): Response
|
||||
'today' => $c,
|
||||
];
|
||||
})->values();
|
||||
});
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DebtController extends Controller
|
||||
{
|
||||
//
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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')
|
||||
$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')
|
||||
->with(['assignedUser:id,name', 'user:id,name', 'contract:id,uuid'])
|
||||
->get();
|
||||
)
|
||||
->latest('id');
|
||||
|
||||
$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();
|
||||
}
|
||||
$unassignedClients = $unassignedContracts->get()
|
||||
->pluck('clientCase.client')
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
|
||||
$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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
@@ -42,6 +64,7 @@ public function index(Request $request)
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'from' => $paginator->firstItem(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'links' => $paginator->linkCollection()->toArray(),
|
||||
'path' => $paginator->path(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'to' => $paginator->lastItem(),
|
||||
@@ -164,9 +187,25 @@ public function store(Request $request)
|
||||
public function process(Import $import, Request $request, ImportProcessor $processor)
|
||||
{
|
||||
$import->update(['status' => 'validating', 'started_at' => now()]);
|
||||
|
||||
try {
|
||||
$result = $processor->process($import, user: $request->user());
|
||||
|
||||
return response()->json($result);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Import processing failed', [
|
||||
'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 +444,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 +532,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 +712,8 @@ public function simulatePayments(Import $import, Request $request)
|
||||
* using the first N rows and current saved mappings. Works for both payments and non-payments
|
||||
* 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 +724,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 +826,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateInstallmentSettingRequest;
|
||||
use App\Models\Action;
|
||||
use App\Models\Decision;
|
||||
use App\Models\InstallmentSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class InstallmentSettingController extends Controller
|
||||
{
|
||||
public function edit(): Response
|
||||
{
|
||||
$setting = InstallmentSetting::query()->first();
|
||||
if (! $setting) {
|
||||
$setting = InstallmentSetting::query()->create([
|
||||
'default_currency' => 'EUR',
|
||||
'create_activity_on_installment' => false,
|
||||
'default_decision_id' => null,
|
||||
'default_action_id' => null,
|
||||
'activity_note_template' => 'Dodan obrok: {amount} {currency}',
|
||||
]);
|
||||
}
|
||||
|
||||
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
|
||||
$actions = Action::query()
|
||||
->with(['decisions:id'])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (Action $a) {
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'decision_ids' => $a->decisions->pluck('id')->values(),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Settings/Installments/Index', [
|
||||
'setting' => [
|
||||
'id' => $setting->id,
|
||||
'default_currency' => $setting->default_currency,
|
||||
'create_activity_on_installment' => (bool) $setting->create_activity_on_installment,
|
||||
'default_decision_id' => $setting->default_decision_id,
|
||||
'default_action_id' => $setting->default_action_id,
|
||||
'activity_note_template' => $setting->activity_note_template,
|
||||
],
|
||||
'decisions' => $decisions,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateInstallmentSettingRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$setting = InstallmentSetting::query()->firstOrFail();
|
||||
|
||||
$data['create_activity_on_installment'] = (bool) ($data['create_activity_on_installment'] ?? false);
|
||||
|
||||
$setting->update($data);
|
||||
|
||||
return back()->with('success', 'Nastavitve shranjene.');
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public function unread(Request $request)
|
||||
}
|
||||
|
||||
$today = now()->toDateString();
|
||||
$perPage = max(1, min(100, (int) $request->integer('perPage', 15)));
|
||||
$perPage = max(1, min(100, (int) $request->integer('per_page', 15)));
|
||||
$search = trim((string) $request->input('search', ''));
|
||||
$clientUuid = trim((string) $request->input('client', ''));
|
||||
$clientId = null;
|
||||
@@ -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
|
||||
{
|
||||
//
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BankAccount;
|
||||
use App\Models\Person\Person;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -22,14 +21,14 @@ public function update(Person $person, Request $request)
|
||||
'tax_number' => 'nullable|integer',
|
||||
'social_security_number' => 'nullable|integer',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'employer' => 'nullable|string|max:255',
|
||||
'birthday' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$person->update($attributes);
|
||||
|
||||
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function createAddress(Person $person, Request $request)
|
||||
@@ -80,7 +79,6 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
|
||||
$address = $person->addresses()->findOrFail($address_id);
|
||||
$address->delete(); // soft delete
|
||||
|
||||
|
||||
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
|
||||
}
|
||||
|
||||
@@ -138,12 +136,19 @@ public function createEmail(Person $person, Request $request)
|
||||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'valid' => 'boolean',
|
||||
'failed' => 'boolean',
|
||||
'receive_auto_mails' => 'sometimes|boolean',
|
||||
'verified_at' => 'nullable|date',
|
||||
'preferences' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
'decision_ids' => 'nullable|array',
|
||||
'decision_ids.*' => 'integer|exists:decisions,id',
|
||||
]);
|
||||
|
||||
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
||||
unset($attributes['decision_ids']);
|
||||
$attributes['preferences'] = array_merge($attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
||||
|
||||
// Dedup: avoid duplicate email per person by value
|
||||
$email = $person->emails()->firstOrCreate([
|
||||
'value' => $attributes['value'],
|
||||
@@ -160,14 +165,21 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
||||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'valid' => 'boolean',
|
||||
'failed' => 'boolean',
|
||||
'receive_auto_mails' => 'sometimes|boolean',
|
||||
'verified_at' => 'nullable|date',
|
||||
'preferences' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
'decision_ids' => 'nullable|array',
|
||||
'decision_ids.*' => 'integer|exists:decisions,id',
|
||||
]);
|
||||
|
||||
$email = $person->emails()->findOrFail($email_id);
|
||||
|
||||
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
||||
unset($attributes['decision_ids']);
|
||||
$attributes['preferences'] = array_merge($email->preferences ?? [], $attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
||||
|
||||
$email->update($attributes);
|
||||
|
||||
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
|
||||
@@ -204,10 +216,8 @@ public function createTrr(Person $person, Request $request)
|
||||
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
||||
$trr = $person->bankAccounts()->create($attributes);
|
||||
|
||||
|
||||
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function updateTrr(Person $person, int $trr_id, Request $request)
|
||||
@@ -238,7 +248,6 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
|
||||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||
$trr->delete();
|
||||
|
||||
|
||||
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
|
||||
|
||||
}
|
||||
|
||||
@@ -10,45 +10,97 @@
|
||||
class PhoneViewController extends Controller
|
||||
{
|
||||
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
||||
public function index(Request $request)
|
||||
|
||||
public function index(Request $request): \Inertia\Response
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$search = $request->input('search');
|
||||
$clientFilter = $request->input('client');
|
||||
|
||||
$jobs = FieldJob::query()
|
||||
->where('assigned_user_id', $userId)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->with([
|
||||
$eagerLoad = [
|
||||
'contract' => function ($q) {
|
||||
$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',
|
||||
]);
|
||||
},
|
||||
];
|
||||
|
||||
$baseQuery = FieldJob::query()
|
||||
->where('assigned_user_id', $userId)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->with($eagerLoad);
|
||||
|
||||
if ($clientFilter) {
|
||||
$baseQuery->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
|
||||
$q->where('uuid', $clientFilter);
|
||||
});
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$baseQuery->where(function ($q) use ($search) {
|
||||
$q->whereHas('contract', function ($cq) use ($search) {
|
||||
$cq->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($pq) use ($search) {
|
||||
$pq->where('full_name', 'ilike', '%'.$search.'%');
|
||||
})
|
||||
->orWhereHas('clientCase.client.person', function ($pq) use ($search) {
|
||||
$pq->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$pendingQuery = (clone $baseQuery)
|
||||
->where(fn ($q) => $q->where('added_activity', false)->orWhereNull('added_activity'))
|
||||
->orderByDesc('assigned_at');
|
||||
|
||||
$processedQuery = (clone $baseQuery)
|
||||
->where('added_activity', true)
|
||||
->orderByDesc('assigned_at');
|
||||
|
||||
$clients = \App\Models\Client::query()
|
||||
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
|
||||
$q->where('assigned_user_id', $userId)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at');
|
||||
})
|
||||
->with(['person:id,full_name'])
|
||||
->get(['uuid', 'person_id'])
|
||||
->map(fn ($c) => [
|
||||
'uuid' => (string) $c->uuid,
|
||||
'name' => (string) optional($c->person)->full_name,
|
||||
])
|
||||
->orderByDesc('assigned_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values();
|
||||
|
||||
return Inertia::render('Phone/Index', [
|
||||
'jobs' => $jobs,
|
||||
'pendingJobs' => Inertia::scroll(fn () => $pendingQuery->paginate(15, pageName: 'pending')),
|
||||
'processedJobs' => Inertia::scroll(fn () => $processedQuery->paginate(15, pageName: 'processed')),
|
||||
'clients' => $clients,
|
||||
'view_mode' => 'assigned',
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
'client' => $clientFilter,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function completedToday(Request $request)
|
||||
public function completedToday(Request $request): \Inertia\Response
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$search = $request->input('search');
|
||||
$clientFilter = $request->input('client');
|
||||
|
||||
$start = now()->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
|
||||
$jobs = FieldJob::query()
|
||||
$query = FieldJob::query()
|
||||
->where('assigned_user_id', $userId)
|
||||
->whereNull('cancelled_at')
|
||||
->whereBetween('completed_at', [$start, $end])
|
||||
@@ -57,190 +109,141 @@ 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.'%');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$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,
|
||||
'completedJobs' => Inertia::scroll(fn () => $query->paginate(15, pageName: 'completed')),
|
||||
'clients' => $clients,
|
||||
'view_mode' => 'completed-today',
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
'client' => $clientFilter,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
||||
{
|
||||
$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()
|
||||
// Query contracts based on field jobs
|
||||
$contractsQuery = 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();
|
||||
}
|
||||
->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')
|
||||
// Build merged documents
|
||||
$documents = $case->documents()
|
||||
->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()
|
||||
->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', $contractIds)
|
||||
->whereIn('documentable_id', $contracts->pluck('id'))
|
||||
->with('documentable:id,uuid,reference')
|
||||
->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;
|
||||
->map(fn ($d) => array_merge($d->toArray(), [
|
||||
'contract_reference' => $d->documentable?->reference,
|
||||
'contract_uuid' => $d->documentable?->uuid,
|
||||
]))
|
||||
)
|
||||
->sortByDesc('created_at')
|
||||
->values();
|
||||
|
||||
return $arr;
|
||||
});
|
||||
// Get segment IDs for filtering actions
|
||||
$segmentIds = \App\Models\FieldJobSetting::query()
|
||||
->whereIn('id', $contractsQuery->pluck('field_job_setting_id')->filter()->unique())
|
||||
->pluck('segment_id')
|
||||
->filter()
|
||||
->unique();
|
||||
|
||||
$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()
|
||||
return Inertia::render('Phone/Case/Index', [
|
||||
'client' => $case->client->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts'),
|
||||
'client_case' => $case,
|
||||
'contracts' => $contracts,
|
||||
'documents' => $documents,
|
||||
'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(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');
|
||||
}
|
||||
)
|
||||
->pluck('field_job_setting_id')
|
||||
->filter()
|
||||
->unique()
|
||||
->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']);
|
||||
|
||||
return Inertia::render('Phone/Case/Index', [
|
||||
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(),
|
||||
'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,
|
||||
->map(fn ($a) => $a->setAttribute('user_name', $a->user?->name)),
|
||||
'completed_mode' => $completedMode,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -266,7 +281,7 @@ public function clients(Request $request)
|
||||
->get()
|
||||
->map(fn ($c) => [
|
||||
'id' => $c->uuid,
|
||||
'name' => $c->person->full_name ?? 'Unknown'
|
||||
'name' => $c->person->full_name ?? 'Unknown',
|
||||
])
|
||||
->sortBy('name')
|
||||
->values();
|
||||
@@ -274,6 +289,41 @@ public function clients(Request $request)
|
||||
return response()->json($clients);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight actions lookup for select:action filters.
|
||||
*/
|
||||
public function actions(Request $request)
|
||||
{
|
||||
$actions = \App\Models\Action::query()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name'])
|
||||
->map(fn ($a) => ['id' => $a->id, 'name' => $a->name])
|
||||
->values();
|
||||
|
||||
return response()->json($actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight decisions lookup for select:decision filters.
|
||||
* Optionally filtered by action_id (for dependent filter UI).
|
||||
*/
|
||||
public function decisions(Request $request)
|
||||
{
|
||||
$actionId = $request->integer('action_id', 0) ?: null;
|
||||
|
||||
$q = \App\Models\Decision::query()->orderBy('name');
|
||||
|
||||
if ($actionId !== null) {
|
||||
$q->whereHas('actions', fn ($qq) => $qq->where('actions.id', $actionId));
|
||||
}
|
||||
|
||||
$decisions = $q->get(['id', 'name'])
|
||||
->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])
|
||||
->values();
|
||||
|
||||
return response()->json($decisions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build validation rules based on inputs descriptor and validate.
|
||||
*
|
||||
@@ -292,6 +342,8 @@ protected function validateFilters(array $inputs, Request $request): array
|
||||
'integer' => [$nullable, 'integer'],
|
||||
'select:user' => [$nullable, 'integer', 'exists:users,id'],
|
||||
'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
|
||||
'select:action' => [$nullable, 'integer', 'exists:actions,id'],
|
||||
'select:decision' => [$nullable, 'integer', 'exists:decisions,id'],
|
||||
default => [$nullable, 'string'],
|
||||
};
|
||||
}
|
||||
@@ -299,6 +351,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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
use App\Models\Decision;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\Segment;
|
||||
use App\Services\DecisionEvents\ConditionEvaluator;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
@@ -22,6 +23,8 @@ public function index(Request $request)
|
||||
'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']),
|
||||
'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']),
|
||||
'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']),
|
||||
'condition_fields' => ConditionEvaluator::availableFields(),
|
||||
'condition_operators' => ConditionEvaluator::availableOperators(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -83,6 +86,9 @@ public function updateAction(int $id, Request $request)
|
||||
|
||||
public function storeDecision(Request $request)
|
||||
{
|
||||
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
|
||||
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
|
||||
|
||||
$attributes = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'color_tag' => 'nullable|string|max:25',
|
||||
@@ -96,6 +102,14 @@ public function storeDecision(Request $request)
|
||||
'events.*.active' => 'sometimes|boolean',
|
||||
'events.*.run_order' => 'nullable|integer',
|
||||
'events.*.config' => 'nullable|array',
|
||||
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
|
||||
'events.*.config.deactivate_previous' => 'sometimes|boolean',
|
||||
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
|
||||
'events.*.config.reactivate' => 'sometimes|boolean',
|
||||
'events.*.config.conditions' => 'nullable|array',
|
||||
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
|
||||
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
|
||||
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
|
||||
]);
|
||||
|
||||
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
||||
@@ -112,12 +126,12 @@ public function storeDecision(Request $request)
|
||||
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||
if ($key === 'add_segment') {
|
||||
$seg = $ev['config']['segment_id'] ?? null;
|
||||
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
|
||||
if (empty($seg)) {
|
||||
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
||||
}
|
||||
} elseif ($key === 'archive_contract') {
|
||||
$as = $ev['config']['archive_setting_id'] ?? null;
|
||||
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) {
|
||||
if (empty($as)) {
|
||||
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
|
||||
}
|
||||
}
|
||||
@@ -174,6 +188,9 @@ public function updateDecision(int $id, Request $request)
|
||||
{
|
||||
$row = Decision::findOrFail($id);
|
||||
|
||||
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
|
||||
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
|
||||
|
||||
$attributes = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'color_tag' => 'nullable|string|max:25',
|
||||
@@ -187,6 +204,14 @@ public function updateDecision(int $id, Request $request)
|
||||
'events.*.active' => 'sometimes|boolean',
|
||||
'events.*.run_order' => 'nullable|integer',
|
||||
'events.*.config' => 'nullable|array',
|
||||
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
|
||||
'events.*.config.deactivate_previous' => 'sometimes|boolean',
|
||||
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
|
||||
'events.*.config.reactivate' => 'sometimes|boolean',
|
||||
'events.*.config.conditions' => 'nullable|array',
|
||||
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
|
||||
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
|
||||
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
|
||||
]);
|
||||
|
||||
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
||||
@@ -203,12 +228,12 @@ public function updateDecision(int $id, Request $request)
|
||||
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||
if ($key === 'add_segment') {
|
||||
$seg = $ev['config']['segment_id'] ?? null;
|
||||
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
|
||||
if (empty($seg)) {
|
||||
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
||||
}
|
||||
} elseif ($key === 'archive_contract') {
|
||||
$as = $ev['config']['archive_setting_id'] ?? null;
|
||||
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) {
|
||||
if (empty($as)) {
|
||||
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,15 @@ public function share(Request $request): array
|
||||
'info' => fn () => $request->session()->get('info'),
|
||||
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
|
||||
],
|
||||
'callLaterCount' => function () use ($request) {
|
||||
if (! $request->user()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return \App\Models\CallLater::query()
|
||||
->whereNull('completed_at')
|
||||
->count();
|
||||
},
|
||||
'notifications' => function () use ($request) {
|
||||
try {
|
||||
$user = $request->user();
|
||||
@@ -72,7 +81,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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreEmailPackageFromContractsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('manage-settings') ?? false;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', 'in:email'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
|
||||
// Common payload for all items
|
||||
'payload' => ['required', 'array'],
|
||||
'payload.mail_profile_id' => ['nullable', 'integer', 'exists:mail_profiles,id'],
|
||||
'payload.template_id' => ['nullable', 'integer', 'exists:email_templates,id'],
|
||||
'payload.subject' => ['nullable', 'string', 'max:255'],
|
||||
'payload.body_text' => ['nullable', 'string', 'max:10000'],
|
||||
'payload.variables' => ['nullable', 'array'],
|
||||
|
||||
// Source contracts to derive items from
|
||||
'contract_ids' => ['required', 'array', 'min:1'],
|
||||
'contract_ids.*' => ['integer', 'exists:contracts,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ public function rules(): array
|
||||
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
||||
'allow_attachments' => ['sometimes', 'boolean'],
|
||||
'active' => ['boolean'],
|
||||
'client' => ['sometimes', 'boolean'],
|
||||
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreInstallmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'numeric', 'min:0.01'],
|
||||
'currency' => ['nullable', 'string', 'size:3'],
|
||||
'reference' => ['nullable', 'string', 'max:100'],
|
||||
'installment_at' => ['nullable', 'date'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ public function rules(): array
|
||||
'reply_to_name' => ['nullable', 'string', 'max:190'],
|
||||
'priority' => ['nullable', 'integer', 'between:0,65535'],
|
||||
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
|
||||
'signature' => ['nullable', 'array'],
|
||||
'signature.*' => ['nullable', 'string', 'max:1000'],
|
||||
'auto_mailer' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateContractSettingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'create_activity_on_balance_change' => ['sometimes', 'boolean'],
|
||||
'default_action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||
'default_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||
'activity_note_template' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ public function rules(): array
|
||||
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
||||
'allow_attachments' => ['sometimes', 'boolean'],
|
||||
'active' => ['boolean'],
|
||||
'client' => ['sometimes', 'boolean'],
|
||||
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateInstallmentSettingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'default_currency' => ['required', 'string', 'size:3'],
|
||||
'create_activity_on_installment' => ['sometimes', 'boolean'],
|
||||
'default_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||
'default_action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||
'activity_note_template' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ public function rules(): array
|
||||
'priority' => ['nullable', 'integer', 'between:0,65535'],
|
||||
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
|
||||
'active' => ['nullable', 'boolean'],
|
||||
'signature' => ['nullable', 'array'],
|
||||
'signature.*' => ['nullable', 'string', 'max:1000'],
|
||||
'auto_mailer' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public function rules(): array
|
||||
'name' => ['required', 'string', 'max:50'],
|
||||
'description' => ['nullable', 'string', 'max:255'],
|
||||
'active' => ['boolean'],
|
||||
'exclude' => ['boolean']
|
||||
'exclude' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class PersonCollection extends ResourceCollection
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->collection
|
||||
'data' => $this->collection,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||
|
||||
class LoginResponse implements LoginResponseContract
|
||||
{
|
||||
public function toResponse($request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$default = $user?->login_redirect ?: config('fortify.home');
|
||||
|
||||
return redirect()->intended($default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Contract;
|
||||
use App\Models\Email;
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailLogStatus;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\MailProfile;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackageItem;
|
||||
use App\Services\EmailSender;
|
||||
use App\Services\EmailTemplateRenderer;
|
||||
use Illuminate\Bus\Batchable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PackageItemEmailJob implements ShouldQueue
|
||||
{
|
||||
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public int $packageItemId)
|
||||
{
|
||||
$this->onQueue('email');
|
||||
}
|
||||
|
||||
public function handle(EmailTemplateRenderer $renderer, EmailSender $sender): void
|
||||
{
|
||||
/** @var PackageItem|null $item */
|
||||
$item = PackageItem::query()->find($this->packageItemId);
|
||||
if (! $item) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Package $package */
|
||||
$package = $item->package;
|
||||
if (! $package || $package->status === Package::STATUS_CANCELED) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($item->status, ['sent', 'failed', 'canceled', 'skipped'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($item->status === 'queued') {
|
||||
$item->status = 'processing';
|
||||
$item->save();
|
||||
$package->increment('processing_count');
|
||||
}
|
||||
|
||||
$payload = (array) $item->payload_json;
|
||||
$target = (array) $item->target_json;
|
||||
|
||||
$to = $target['email'] ?? null;
|
||||
if (! is_string($to) || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
$item->status = 'failed';
|
||||
$item->last_error = 'Missing or invalid recipient email.';
|
||||
$item->save();
|
||||
$this->updatePackageCounters($item, $package);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$templateId = $payload['template_id'] ?? null;
|
||||
$mailProfileId = $payload['mail_profile_id'] ?? null;
|
||||
$variables = (array) ($payload['variables'] ?? []);
|
||||
$subjectOverride = isset($payload['subject']) ? trim((string) $payload['subject']) : null;
|
||||
if ($subjectOverride === '') {
|
||||
$subjectOverride = null;
|
||||
}
|
||||
$bodyText = isset($payload['body_text']) ? (string) $payload['body_text'] : '';
|
||||
|
||||
// Enrich variables with contract/account context when available
|
||||
$contract = null;
|
||||
if (! empty($target['contract_id'])) {
|
||||
$contract = Contract::query()->with(['clientCase.person', 'account.type'])->find($target['contract_id']);
|
||||
if ($contract) {
|
||||
$variables['contract'] = [
|
||||
'id' => $contract->id,
|
||||
'uuid' => $contract->uuid,
|
||||
'reference' => $contract->reference,
|
||||
'start_date' => (string) ($contract->start_date ?? ''),
|
||||
'end_date' => (string) ($contract->end_date ?? ''),
|
||||
];
|
||||
if (is_array($contract->meta) && ! empty($contract->meta)) {
|
||||
$variables['contract']['meta'] = $this->flattenMeta($contract->meta);
|
||||
}
|
||||
if ($contract->account) {
|
||||
$initialRaw = (string) $contract->account->initial_amount;
|
||||
$balanceRaw = (string) $contract->account->balance_amount;
|
||||
$variables['account'] = [
|
||||
'id' => $contract->account->id,
|
||||
'reference' => $contract->account->reference,
|
||||
'initial_amount' => $this->formatAmountEu($initialRaw),
|
||||
'balance_amount' => $this->formatAmountEu($balanceRaw),
|
||||
'initial_amount_raw' => $initialRaw,
|
||||
'balance_amount_raw' => $balanceRaw,
|
||||
'type' => $contract->account->type?->name,
|
||||
];
|
||||
}
|
||||
if ($contract->clientCase?->person) {
|
||||
$person = $contract->clientCase->person;
|
||||
$variables['person'] = [
|
||||
'full_name' => $person->full_name,
|
||||
'first_name' => $person->first_name,
|
||||
'last_name' => $person->last_name,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @var EmailTemplate|null $template */
|
||||
$template = $templateId ? EmailTemplate::with(['action', 'decision'])->find((int) $templateId) : null;
|
||||
|
||||
/** @var MailProfile|null $mailProfile */
|
||||
$mailProfile = $mailProfileId
|
||||
? MailProfile::find((int) $mailProfileId)
|
||||
: MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first();
|
||||
|
||||
try {
|
||||
if (! $template && ! $subjectOverride) {
|
||||
throw new \RuntimeException('No email template or subject provided.');
|
||||
}
|
||||
|
||||
$rendered = $template
|
||||
? $renderer->render([
|
||||
'subject' => $subjectOverride ?? (string) $template->subject_template,
|
||||
'html' => (string) $template->html_template,
|
||||
'text' => (string) $template->text_template,
|
||||
], array_filter([
|
||||
'contract' => $contract,
|
||||
'person' => $contract?->clientCase?->person,
|
||||
'client' => $contract?->clientCase?->client,
|
||||
'client_case' => $contract?->clientCase,
|
||||
'mail_profile' => $mailProfile,
|
||||
'extra' => $variables,
|
||||
'body_text' => $bodyText !== '' ? $bodyText : null,
|
||||
]))
|
||||
: [
|
||||
'subject' => $subjectOverride ?? '',
|
||||
'html' => null,
|
||||
'text' => null,
|
||||
];
|
||||
|
||||
$log = new EmailLog;
|
||||
$log->fill([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'template_id' => $template?->id,
|
||||
'mail_profile_id' => $mailProfile?->id,
|
||||
'to_email' => $to,
|
||||
'to_recipients' => [$to],
|
||||
'subject' => $rendered['subject'],
|
||||
'body_html_hash' => isset($rendered['html']) ? hash('sha256', (string) $rendered['html']) : null,
|
||||
'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null,
|
||||
'embed_mode' => 'base64',
|
||||
'status' => EmailLogStatus::Queued,
|
||||
'queued_at' => now(),
|
||||
'contract_id' => $contract?->id,
|
||||
'client_id' => $contract?->clientCase?->client?->id,
|
||||
'client_case_id' => $contract?->clientCase?->id,
|
||||
'extra_context' => ['package_id' => $item->package_id, 'package_item_id' => $item->id],
|
||||
]);
|
||||
$log->save();
|
||||
|
||||
$log->body()->create([
|
||||
'body_html' => (string) ($rendered['html'] ?? ''),
|
||||
'body_text' => (string) ($rendered['text'] ?? ''),
|
||||
'inline_css' => true,
|
||||
]);
|
||||
|
||||
// Send directly (synchronous within job context)
|
||||
$start = microtime(true);
|
||||
$log->status = EmailLogStatus::Sending;
|
||||
$log->started_at = now();
|
||||
$log->attempt = 1;
|
||||
$log->save();
|
||||
|
||||
$sender->sendFromLog($log);
|
||||
|
||||
$log->status = EmailLogStatus::Sent;
|
||||
$log->sent_at = now();
|
||||
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
|
||||
$log->save();
|
||||
|
||||
$item->status = 'sent';
|
||||
$item->result_json = ['email_log_id' => $log->id, 'subject' => $rendered['subject']];
|
||||
$item->last_error = null;
|
||||
$item->save();
|
||||
|
||||
// Clear failed flag on successful delivery
|
||||
Email::query()->where('value', $to)->where('failed', true)->update(['failed' => false]);
|
||||
|
||||
// Create activity if the template has action/decision configured
|
||||
if ($template && ($template->action_id || $template->decision_id) && $contract && $contract->client_case_id) {
|
||||
$activity = \App\Models\Activity::create(array_filter([
|
||||
'client_case_id' => $contract->client_case_id,
|
||||
'contract_id' => $contract->id,
|
||||
'action_id' => $template->action_id,
|
||||
'decision_id' => $template->decision_id,
|
||||
'note' => 'Poslano: '.$to.', Uspešno'.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
|
||||
]));
|
||||
$activity->emailLogs()->attach($log->id);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$item->status = 'failed';
|
||||
$item->last_error = $e->getMessage();
|
||||
$item->save();
|
||||
|
||||
// Create activity for failed send if the template has action/decision configured
|
||||
if ($template && ($template->action_id || $template->decision_id) && isset($contract) && $contract && $contract->client_case_id) {
|
||||
$shortError = mb_strimwidth($e->getMessage(), 0, 120, '…');
|
||||
$activity = \App\Models\Activity::create(array_filter([
|
||||
'client_case_id' => $contract->client_case_id,
|
||||
'contract_id' => $contract->id,
|
||||
'action_id' => $template->action_id,
|
||||
'decision_id' => $template->decision_id,
|
||||
'note' => 'Poslano: '.$to.', Napaka pri pošiljanju: '.$shortError.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
|
||||
]));
|
||||
if (isset($log) && $log->exists) {
|
||||
$activity->emailLogs()->attach($log->id);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the email address as failed in the DB.
|
||||
if (isset($to)) {
|
||||
Email::query()
|
||||
->where('value', $to)
|
||||
->update(['failed' => true]);
|
||||
}
|
||||
|
||||
// Permanent SMTP rejection (550 user unknown, 551 not local, 553 invalid address)
|
||||
// means the address definitively does not exist — also mark it invalid.
|
||||
if ($e instanceof \Symfony\Component\Mailer\Exception\TransportExceptionInterface
|
||||
&& preg_match('/\b55[013]\b/', $e->getMessage())
|
||||
&& isset($to)) {
|
||||
Email::query()
|
||||
->where('value', $to)
|
||||
->update(['valid' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->updatePackageCounters($item, $package);
|
||||
}
|
||||
|
||||
private function updatePackageCounters(PackageItem $item, Package $package): void
|
||||
{
|
||||
if ($item->status === 'sent') {
|
||||
$package->increment('sent_count');
|
||||
} else {
|
||||
$package->increment('failed_count');
|
||||
}
|
||||
|
||||
$package->decrement('processing_count');
|
||||
|
||||
$package->refresh();
|
||||
$done = $package->sent_count + $package->failed_count;
|
||||
if ($done >= $package->total_items) {
|
||||
$package->status = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED;
|
||||
$package->finished_at = now();
|
||||
$package->save();
|
||||
}
|
||||
}
|
||||
|
||||
private function flattenMeta(array $meta, string $prefix = ''): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($meta as $key => $value) {
|
||||
$newKey = $prefix === '' ? $key : "{$prefix}.{$key}";
|
||||
if (is_array($value)) {
|
||||
if (isset($value['value'])) {
|
||||
$result[$newKey] = $value['value'];
|
||||
} else {
|
||||
$nested = $this->flattenMeta($value, $newKey);
|
||||
$result = array_merge($result, $nested);
|
||||
}
|
||||
} else {
|
||||
$result[$newKey] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function formatAmountEu(string $raw): string
|
||||
{
|
||||
$numeric = preg_replace('/[^0-9.]/', '', $raw);
|
||||
$float = (float) $numeric;
|
||||
|
||||
return number_format($float, 2, ',', '.');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\Event as DecisionEventModel;
|
||||
use App\Services\DecisionEvents\ConditionEvaluator;
|
||||
use App\Services\DecisionEvents\DecisionEventContext;
|
||||
use App\Services\DecisionEvents\Registry;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -68,6 +69,23 @@ public function handle(): void
|
||||
user: $activity->user,
|
||||
);
|
||||
|
||||
// [2] Condition check — skip the event if any condition is not met
|
||||
$conditions = $this->config['conditions'] ?? [];
|
||||
if (! empty($conditions)) {
|
||||
$conditionsMet = app(ConditionEvaluator::class)->evaluate($conditions, $context);
|
||||
if (! $conditionsMet) {
|
||||
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
||||
'status' => 'skipped',
|
||||
'message' => 'Condition not met',
|
||||
'finished_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// [3] Resolve handler → handle()
|
||||
$handler->handle($context, $this->config);
|
||||
|
||||
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Email;
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailLogStatus;
|
||||
use App\Services\EmailSender;
|
||||
@@ -53,6 +54,10 @@ public function handle(): void
|
||||
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
|
||||
$log->save();
|
||||
|
||||
if ($log->to_email) {
|
||||
Email::query()->where('value', $log->to_email)->update(['failed' => true]);
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,10 +118,10 @@ public function handle(SmsService $sms): void
|
||||
if ($template && $case) {
|
||||
$note = '';
|
||||
if ($log->status === 'sent') {
|
||||
$note = sprintf('Št: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
||||
$note = sprintf('Tel: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
||||
} elseif ($log->status === 'failed') {
|
||||
$note = sprintf(
|
||||
'Št: %s | Telo: %s | Napaka: %s',
|
||||
'Tel: %s | Telo: %s | Napaka: %s',
|
||||
(string) $this->to,
|
||||
(string) $this->content,
|
||||
'SMS ni bil poslan!'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Account extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'reference',
|
||||
'description',
|
||||
@@ -56,6 +59,11 @@ public function payments(): HasMany
|
||||
return $this->hasMany(\App\Models\Payment::class);
|
||||
}
|
||||
|
||||
public function installments(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Installment::class);
|
||||
}
|
||||
|
||||
public function bookings(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Booking::class);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Activity extends Model
|
||||
@@ -18,6 +19,7 @@ class Activity extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'due_date',
|
||||
'call_back_at',
|
||||
'amount',
|
||||
'note',
|
||||
'action_id',
|
||||
@@ -27,6 +29,13 @@ class Activity extends Model
|
||||
'client_case_id',
|
||||
];
|
||||
|
||||
/*protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'call_back_at' => 'datetime',
|
||||
];
|
||||
}*/
|
||||
|
||||
protected $hidden = [
|
||||
'action_id',
|
||||
'decision_id',
|
||||
@@ -146,4 +155,14 @@ public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class);
|
||||
}
|
||||
|
||||
public function callLaters(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\CallLater::class);
|
||||
}
|
||||
|
||||
public function emailLogs(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(EmailLog::class, 'activity_email_logs');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CallLater extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'activity_id',
|
||||
'client_case_id',
|
||||
'contract_id',
|
||||
'user_id',
|
||||
'call_back_at',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'call_back_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function activity(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Activity::class);
|
||||
}
|
||||
|
||||
public function clientCase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientCase::class);
|
||||
}
|
||||
|
||||
public function contract(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Contract::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ContractSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'create_activity_on_balance_change',
|
||||
'default_action_id',
|
||||
'default_decision_id',
|
||||
'activity_note_template',
|
||||
];
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class Email extends Model
|
||||
'is_primary',
|
||||
'is_active',
|
||||
'valid',
|
||||
'failed',
|
||||
'receive_auto_mails',
|
||||
'verified_at',
|
||||
'preferences',
|
||||
@@ -28,6 +29,7 @@ class Email extends Model
|
||||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'valid' => 'boolean',
|
||||
'failed' => 'boolean',
|
||||
'receive_auto_mails' => 'boolean',
|
||||
'verified_at' => 'datetime',
|
||||
'preferences' => 'array',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
enum EmailLogStatus: string
|
||||
@@ -83,4 +84,9 @@ public function body(): HasOne
|
||||
{
|
||||
return $this->hasOne(EmailLogBody::class, 'email_log_id');
|
||||
}
|
||||
|
||||
public function activities(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Activity::class, 'activity_email_logs');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
class EmailTemplate extends Model
|
||||
@@ -19,10 +20,14 @@ class EmailTemplate extends Model
|
||||
'entity_types',
|
||||
'allow_attachments',
|
||||
'active',
|
||||
'action_id',
|
||||
'decision_id',
|
||||
'client',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => 'boolean',
|
||||
'client' => 'boolean',
|
||||
'entity_types' => 'array',
|
||||
'allow_attachments' => 'boolean',
|
||||
];
|
||||
@@ -31,4 +36,14 @@ public function documents(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Document::class, 'documentable');
|
||||
}
|
||||
|
||||
public function action(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Action::class);
|
||||
}
|
||||
|
||||
public function decision(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Decision::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Installment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'account_id',
|
||||
'amount',
|
||||
'balance_before',
|
||||
'currency',
|
||||
'reference',
|
||||
'installment_at',
|
||||
'meta',
|
||||
'created_by',
|
||||
'activity_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'installment_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
'amount' => 'decimal:4',
|
||||
'balance_before' => 'decimal:4',
|
||||
];
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
|
||||
public function activity(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Activity::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InstallmentSetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'default_currency',
|
||||
'create_activity_on_installment',
|
||||
'default_decision_id',
|
||||
'default_action_id',
|
||||
'activity_note_template',
|
||||
];
|
||||
}
|
||||
@@ -10,13 +10,15 @@ class MailProfile extends Model
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'active', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
|
||||
'reply_to_address', 'reply_to_name', 'priority', 'max_daily_quota', 'emails_sent_today',
|
||||
'name', 'active', 'auto_mailer', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
|
||||
'reply_to_address', 'reply_to_name', 'priority', 'signature', 'max_daily_quota', 'emails_sent_today',
|
||||
'last_success_at', 'last_error_at', 'last_error_message', 'failover_to_id', 'test_status', 'test_checked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => 'boolean',
|
||||
'auto_mailer' => 'boolean',
|
||||
'signature' => 'array',
|
||||
'last_success_at' => 'datetime',
|
||||
'last_error_at' => 'datetime',
|
||||
'test_checked_at' => 'datetime',
|
||||
|
||||
@@ -34,6 +34,8 @@ public function items()
|
||||
|
||||
public const TYPE_SMS = 'sms';
|
||||
|
||||
public const TYPE_EMAIL = 'email';
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,4 @@ public function persons(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Person\Person::class);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ class Post extends Model
|
||||
public function toSearchableArray()
|
||||
{
|
||||
$array = $this->toArray();
|
||||
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user