diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c2c59e9 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..87a798e --- /dev/null +++ b/.env.local.example @@ -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= diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..a65afd1 --- /dev/null +++ b/.env.production.example @@ -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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 30545f3..b523e3b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -22,7 +22,7 @@ ## Foundational Context - pestphp/pest (PEST) - v3 - phpunit/phpunit (PHPUNIT) - v11 - @inertiajs/vue3 (INERTIA) - v2 -- tailwindcss (TAILWINDCSS) - v3 +- tailwindcss (TAILWINDCSS) - v4 - vue (VUE) - v3 @@ -359,11 +359,39 @@ ### Dark Mode - If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. -=== tailwindcss/v3 rules === +=== tailwindcss/v4 rules === -## Tailwind 3 +## Tailwind 4 -- Always use Tailwind CSS v3 - verify you're using only classes supported by this version. +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + - @tailwind base; + - @tailwind components; + - @tailwind utilities; + + @import "tailwindcss"; + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | === tests rules === diff --git a/.gitignore b/.gitignore index afa306b..9c27a86 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/DEDUPLICATION_PLAN_V2.md b/DEDUPLICATION_PLAN_V2.md new file mode 100644 index 0000000..dcab8bb --- /dev/null +++ b/DEDUPLICATION_PLAN_V2.md @@ -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 diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..094daca --- /dev/null +++ b/DEPLOYMENT_GUIDE.md @@ -0,0 +1,1045 @@ +# Teren App - Ubuntu 24.04 VPS Deployment Guide + +This guide covers the complete setup of the Teren App on Ubuntu 24.04 using Docker, including SSL, WireGuard VPN, and automated deployments. + +## Table of Contents +1. [Prerequisites](#prerequisites) +2. [Initial VPS Setup](#initial-vps-setup) +3. [Docker Installation](#docker-installation) +4. [WireGuard VPN Setup](#wireguard-vpn-setup) ⚠️ **SETUP FIRST - APP IS VPN-ONLY** +5. [Application Setup](#application-setup) +6. [SSL Certificate Setup](#ssl-certificate-setup) +7. [Portainer Setup](#portainer-setup) +8. [Automated Deployment Setup](#automated-deployment-setup) +9. [Monitoring & Maintenance](#monitoring--maintenance) +10. [Troubleshooting](#troubleshooting) + +--- + +## Prerequisites + +- Ubuntu 24.04 VPS with root access +- Domain name pointed to your VPS IP (optional - can use IP only) +- Self-hosted Gitea server +- At least 2GB RAM (4GB recommended) +- 20GB+ storage + +**IMPORTANT:** This application is configured to be accessible ONLY through WireGuard VPN. The app will NOT be publicly accessible. + +--- + +## Initial VPS Setup + +### 1. Update System + +```bash +sudo apt update && sudo apt upgrade -y +``` + +### 2. Create Deploy User + +```bash +# Create user +sudo adduser deployer +sudo usermod -aG sudo deployer + +# Switch to deploy user +su - deployer +``` + +### 3. Configure Firewall + +```bash +# Install UFW +sudo apt install ufw -y + +# Allow SSH, HTTP, HTTPS, and WireGuard +sudo ufw allow 22/tcp +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw allow 51820/udp # WireGuard + +# Enable firewall +sudo ufw enable +sudo ufw status +``` + +### 4. Install Essential Tools + +```bash +sudo apt install -y \ + git \ + curl \ + wget \ + unzip \ + vim \ + htop \ + software-properties-common +``` + +--- + +## Docker Installation + +### 1. Install Docker + +```bash +# Add Docker's official GPG key +sudo apt-get update +sudo apt-get install ca-certificates curl +sudo install -m 0755 -d /etc/apt/keyrings +sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc +sudo chmod a+r /etc/apt/keyrings/docker.asc + +# Add repository +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Install Docker +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +``` + +### 2. Configure Docker + +```bash +# Add user to docker group +sudo usermod -aG docker $USER + +# Enable Docker service +sudo systemctl enable docker +sudo systemctl start docker + +# Verify installation +docker --version +docker compose version + +# Log out and back in for group changes to take effect +exit +su - deployer +``` + +---WireGuard VPN Setup + +⚠️ **CRITICAL: Setup WireGuard FIRST before the application!** The app is configured to be VPN-only and will not be publicly accessible. + +### 1. Enable Kernel Modules + +```bash +# Load WireGuard kernel module +sudo apt install -y wireguard-tools +sudo modprobe wireguard + +# Make it persistent +echo "wireguard" | sudo tee -a /etc/modules +``` + +### 2. Configure Environment + +```bash +cd /var/www/Teren-app + +# Edit .env file +vim .env +``` + +Set these WireGuard variables: +- `WG_SERVERURL`: Your VPS public IP or domain (e.g., `vpn.example.com` or `192.168.1.100`) +- `WG_UI_PASSWORD`: Strong password for WireGuard dashboard + +### 3. Start WireGuard Container + +```bash +# Start only WireGuard first +docker compose up -d wireguard + +# Wait for it to initialize +sleep 10 + +# Check status +docker compose logs wireguard +``` + +### 4. Access WireGuard Dashboard + +The WireGuard Web UI is temporarily accessible publicly for initial setup: + +**URL:** `http://YOUR_VPS_IP:51821` + +Login with the password you set in `WG_UI_PASSWORD`. + +### 5. Create Your First Client + +1. In the WireGuard dashboard, click **"New Client"** +2. Give it a name (e.g., "MyLaptop") +3. Click the QR code or download the config file +4. Install WireGuard on your device: + - **Windows:** https://www.wireguard.com/install/ + - **macOS:** App Store + - **Linux:** `sudo apt install wireguard` + - **iOS/Android:** App Store / Play Store + +5. ImpoStart Application Containers + +**Note:** Make sure you're connected to the WireGuard VPN before proceeding. + +```bash +# Start all application containers +docker compose up -d postgres redis app nginx certbot + +# Verify containers are running +docker compose ps + +# Test application (from your VPN-connected machine) +curl http://10.13.13.1 +# Or in browser: http://10.13.13.1 + +You should get responses from the VPN gateway. + +### 7. Secure WireGuard Dashboard (After Setup) + +After creating your initial clients, secure the WireGuard UI by editing `docker-compose.yaml`: +**Note:** SSL certificates are optional since the app is VPN-only. You can use self-signed certificates or skip SSL entirely for internal VPN access. However, if you want proper SSL: + +### Option 1: Self-Signed Certificates (Recommended for VPN-only) + +```bash +# Generate self-signed certificate +sudo mkdir -p docker/nginx/ssl +sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout docker/nginx/ssl/selfsigned.key \ + -out docker/nginx/ssl/selfsigned.crt \ + -subj "/C=SI/ST=Slovenia/L=Ljubljana/O=TerenApp/CN=10.13.13.1" + +# Update nginx config to use self-signed cert +vim docker/nginx/conf.d/app.conf +``` + +Update SSL paths: +```nginx +ssl_certificate /etc/nginx/ssl/selfsigned.crt; +ssl_certificate_key /etc/nginx/ssl/selfsigned.key; +``` + +```bash +# Restart nginx +docker compose restart nginx + +# Test (ignore certificate warning) +curl -k https://10.13.13.1 +``` + +### Option 2: Let's Encrypt (If using domain name) + +Only if you have a domain pointing to your VPS and want valid SSL: + +```bash +# Temporarily allow port 80 publicly +sudo ufw allow 80/tcp + +# Request certificate +docker compose run --rm certbot certonly \ + --standalone \ + --preferred-challenges http \ + --email your-email@example.com \ + --agree-tos \ + --no-eff-email \ + -d your-domain.com + +# Block port 80 again +sudo ufw delete allow 80/tcp + +# Update nginx config with Let's Encrypt paths +# Restart nginx +docker compose restart nginx +``` + +### Option 3: No SSL (HTTP Only) + +For internal VPN-only access, you can skip SSL entirely: + +```bash +# Edit nginx config to remove HTTPS server block +vim docker/nginx/conf.d/app.conf + +# Keep only the HTTP (port 80) server block +# Restart nginx +docker compose restart nginx + +# Access via: http://10.13.13.1 + +```bash +# Create project directory +sudo mkdir -p /var/www +sudo chown -R $USER:$USER /var/www +cd /var/www + +# Clone from your Gitea server +git clone git@your-gitea-server.com:username/Teren-app.git +cd Teren-app +``` + +### 2. Setup Environment + +```bash +# Copy example files +cp .env.production.example .env +cp docker-compose.yaml.example docker-compose.yaml + +# Edit .env file with your actual values +vim .env +``` + +**Important:** Update these values in `.env`: +- `APP_KEY` - Generate with `php artisan key:generate` (run after first container start) +- `APP_URL` - Your domain +- `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` - Strong database credentials +- `PGADMIN_EMAIL`, `PGADMIN_PASSWORD` - pgAdmin credentials + +### 3. Update Docker Compose + +Edit `docker-compose.yaml` and update: +- Replace `example.com` with your domain in nginx volume mounts +- Update environment variables as needed + +### 4. Create Required Directories + +```bash +# Create docker directories +mkdir -p docker/nginx/conf.d +mkdir -p docker/nginx/ssl +mkdir -p docker/certbot/conf +mkdir -p docker/certbot/www +mkdir -p docker/postgres/init +mkdir -p docker/supervisor/conf.d +mkdir -p docker/php + +# Set permissions +sudo chown -R $USER:$USER docker/ +``` + +### 5. Update Nginx Configuration + +Edit `docker/nginx/conf.d/app.conf` and replace `example.com` with your actual domain. + +### 6. Initial SSL Certificate (HTTP-only first) + +Before getting SSL certificates, modify nginx config temporarily: + +```bash +# Comment out SSL sections in docker/nginx/conf.d/app.conf +# Keep only the HTTP (port 80) server block + +# Start containers +docker compose up -d postgres redis app nginx + +# Verify nginx is running +docker compose ps +curl http://your-domain.com # Should get some response +``` + +--- + +## SSL Certificate Setup + +### 1. Obtain Initial Certificate + +```bash +# Request certificate for your domain +docker compose run --rm certbot certonly \ + --webroot \ + --webroot-path=/var/www/certbot \ + --email your-email@example.com \ + --agree-tos \ + --no-eff-email \ + -d your-domain.com \ + -d www.your-domain.com +``` + +### 2. Enable HTTPS in Nginx + +```bash +# Uncomment SSL sections in docker/nginx/conf.d/app.conf +vimPortainer Setup + +Portainer provides a web UI for managing Docker containers, images, networks, and volumes. + +### 1. Access Portainer + +**Prerequisites:** Must be connected to WireGuard VPN + +**URL:** `http://10.13.13.1:9000` or `https://10.13.13.1:9443` + +### 2. Initial Setup + +On first access: + +1. Create admin account: + - Username: `admin` + - Password: (set a strong password) + +2. Select **"Docker"** environment + +3. Click **"Connect"** + +### 3. Portainer Features + +- **Containers:** Start, stop, restart, view logs +- **Images:** Pull, build, remove images +- **Networks:** Manage Docker networks +- **Volumes:** View and manage volumes +- **Stacks:** Deploy docker-compose stacks +- **Events:** Real-time Docker events + +### 4. Useful Portainer Operations + +**View Container Logs:** +1. Go to Containers +2. Click on container name +3. Click "Logs" +4. Select "Auto-refresh" + +**Execute Commands:** +1. Go to Containers +2. Click on container +3. Click "Console" +4. Select "/bin/sh" or "/bin/bash" + +**Update Container:** +1. Go to Containers +2. Click container name +3. Click "Recreate" +4. Enable "Pull latest image" + +### 5. Security Notes + +- Portainer is only accessible via VPN (bound to 10.13.13.1) +- Always use strong passwords +- Enable 2FA if deploying Portainer Business Edition +- Regularly backup Portainer data volume + - "127.0.0.1:5432:5432" + +# To: +ports: + - "10.13.13.1:5432:5432" # WireGuard VPN subnet +``` + +Then restart: +```bash +docker compose down +docker compose up -d +``` + +### 5. Connect Client + +Install WireGuard on your local machine: +- **Linux:** `sudo apt install wireguard` +- **macOS:** Download from App Store +- **Windows:** Download from wireguard.com + +Import the client config and connect. + +### 6. Access Services via VPN + +Once connected to VPN: +- pgAdmin: `http://10.13.13.1:5050` +- PostgreSQL: `10.13.13.1:5432` + +--- + +## Automated Deployment Setup + +### Method 1: Gitea Webhooks (Recommended) + +#### 1. Setup Webhook Listener on VPS + +```bash +# Create webhook handler script +sudo vim /usr/local/bin/webhook-handler.sh +``` + +Add: +```bash +#!/bin/bash +# Simple webhook handler + +PORT=9000 +SECRET="your-webhook-secret" + +while true; do + echo "Listening for webhooks on port $PORT..." + echo -e "HTTP/1.1 200 OK\n\n" | nc -l -p $PORT -q 1 > /tmp/webhook.log + + # Verify secret (basic) + if grep -q "$SECRET" /tmp/webhook.log; then + echo "Valid webhook received, deploying..." + cd /var/www/Teren-app && ./deploy.sh >> /var/log/deploy.log 2>&1 + fi +done +``` + +```bash +# Make executable +sudo chmod +x /usr/local/bin/webhook-handler.sh + +# Create systemd service +sudo vim /etc/systemd/system/webhook-handler.service +``` + +Add: +```ini +[Unit] +Description=Gitea Webhook Handler +After=network.target + +[Service] +Type=simple +User=deployer +ExecStart=/usr/local/bin/webhook-handler.sh +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +```bash +# Enable and start +sudo systemctl enable webhook-handler +sudo systemctl start webhook-handler + +# Allow webhook port in firewall +sudo ufw allow 9000/tcp +``` + +#### 2. Configure Gitea Webhook + +In your Gitea repository: +1. Go to Settings → Webhooks +2. Add webhook: + - Payload URL: `http://your-vps-ip:9000` + - Secret: `your-webhook-secret` + - Trigger: Push events on `main` branch +3. Test webhook + +### Method 2: Gitea Actions (Recommended for CI/CD) + +#### 1. Enable Gitea Actions + +On your Gitea server, edit `app.ini`: +```ini +[actions] +ENABLED = true +``` + +#### 2. Setup Actions Runner on VPS + +```bash +# Download runner +cd ~ +wget https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-amd64 +chmod +x act_runner-linux-amd64 +sudo mv act_runner-linux-amd64 /usr/local/bin/act-runner + +# Register runner +act-runner register --instance https://your-gitea.com --token YOUR_RUNNER_TOKEN + +# Generate config +act-runner generate-config > runner-config.yaml + +# Create systemd service +sudo vim /etc/systemd/system/act-runner.service +``` + +Add: +```ini +[Unit] +Description=Gitea Actions Runner +After=network.target + +[Service] +Type=simple +User=deployer +WorkingDirectory=/home/deployer +ExecStart=/usr/local/bin/act-runner daemon --config /home/deployer/runner-config.yaml +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable act-runner +sudo systemctl start act-runner +``` + +#### 3. Create Workflow File + +In your repository, create `.gitea/workflows/deploy.yaml`: + +```yaml +name: Deploy to Production + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.VPS_HOST }} + username: deployer + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cd /var/www/Teren-app + ./deploy.sh +``` + +Add secrets in Gitea: Settings → Secrets → Actions +- `VPS_HOST`: Your VPS IP/domain +- `SSH_PRIVATE_KEY`: SSH key for deployer user + +### 3. Make Deploy Script Executable + +```bash +cd /var/www/Teren-app +chmod +x deploy.sh + +# Edit script to match your setup +vim deploy.sh +``` + +### 4. Test Deployment + +```bash +# Manual test +./deploy.sh + +# Or trigger via git push +git commit --allow-empty -m "Test deployment" +git push origin main +``` + +--- + +## Application Initialization + +### 1. Start All Containers + +```bash +cd /var/www/Teren-app +docker compose up -d +``` + +### 2. Generate Application Key + +```bash +docker compose exec app php artisan key:generate +``` + +### 3. Run Migrations + +```bash +docker compose exec app php artisan migrate --force +``` + +### 4. Seed Database (if needed) + +```bash +docker compose exec app php artisan db:seed --force +``` + +### 5. Cache Configuration + +```bash +docker compose exec app php artisan config:cache +docker compose exec app php artisan route:cache +docker compose exec app php artisan view:cache +``` + +### 6. Set Permissions + +```bash +docker compose exec app chown -R www:www /var/www/storage +docker compose exec app chown -R www:www /var/www/bootstrap/cache +``` + +### 7. Verify Installation + +```bash +# Check containers +docker compose ps + +# Check logs +docker compose logs -f app + +# Test application +curl https://your-domain.com +``` + +--- + +## Monitoring & Maintenance + +### Container Management + +```bash +# View all containers +docker compose ps + +# View logs +docker compose logs -f app # Laravel app +docker compose logs -f nginx # Nginx +docker compose logs -f postgres # Database + +# Restart containers +docker compose restart app +docker compose restart nginx + +# Stop all +docker compose down + +# Start all +docker compose up -d +``` + +### Queue Workers + +```bash +# Check queue worker status +docker compose exec app supervisorctl status + +# Restart qServices via VPN + +**All services are only accessible when connected to WireGuard VPN:** + +| Service | URL | Purpose | +|---------|-----|---------| +| Laravel App | `http://10.13.13.1` | Main application | +| pgAdmin | `http://10.13.13.1:5050` | PostgreSQL UI | +| Portainer | `http://10.13.13.1:9000` | Docker management | +| WireGuard UI | `http://10.13.13.1:51821` | VPN management | +| PostgreSQL | `10.13.13.1:5432` | Direct DB connection | +| Redis | `10.13.13.1:6379` | Direct Redis connection | + +**pgAdmin Setup:** +1. Connect to VPN +2. Open: `http://10.13.13.1:5050` +3. Login with `PGADMIN_EMAIL` and `PGADMIN_PASSWORD` +4. Add server: + - Host: `postgres` (or `10.13.13.1` if connecting externally)nt + +```bash +# Connect to PostgreSQL +docker compose exec postgres psql -U teren_user -d teren_app + +# Backup database +docker compose exec postgres pg_dump -U teren_user teren_app > backup-$(date +%Y%m%d).sql + +# Restore database +docker compose exec -T postgres psql -U teren_user teren_app < backup-20260113.sql +``` + +### Access pgAdmin + +Via WireGuard VPN: +1. Connect to VPN +2. Open browser: `http://10.13.13.1:5050` +3. Login with `PGADMIN_EMAIL` and `PGADMIN_PASSWORD` +4. Add server: + - Host: `postgres` + - Port: `5432` + - Database: Your `DB_DATABASE` + - Username: Your `DB_USERNAME` + - Password: Your `DB_PASSWORD` + +### Log Rotation + +```bash +# Install logrotate +sudo apt install logrotate -y + +# Create config +sudo vim /etc/logrotate.d/teren-app +``` + +Add: +``` +/var/www/Teren-app/storage/logs/*.log { + daily + missingok + rotate 14 + compress + delaycompress + notifempty + create 0640 deployer deployer + sharedscripts +} +``` + +### Automated Backups + +Create backup script: +```bash +sudo vim /usr/local/bin/backup-teren.sh +``` + +Add: +```bash +#!/bin/bash +BACKUP_DIR="/backups/teren-app" +DATE=$(date +%Y%m%d_%H%M%S) + +mkdir -p $BACKUP_DIR + +# Backup database +docker compose -f /var/www/Teren-app/docker-compose.yaml exec -T postgres \ + pg_dump -U teren_user teren_app | gzip > $BACKUP_DIR/db-$DATE.sql.gz + +# Backup uploads +tar -czf $BACKUP_DIR/storage-$DATE.tar.gz -C /var/www/Teren-app storage/app/public + +# Keep only last 7 days +find $BACKUP_DIR -type f -mtime +7 -delete + +echo "Backup completed: $DATE" +``` + +```bash +sudo chmod +x /usr/local/bin/backup-teren.sh + +# Add to crontab +crontab -e +``` + +Add: +``` +0 2 * * * /usr/local/bin/backup-teren.sh >> /var/log/backup.log 2>&1 +``` + +--- + +## Troubleshooting + +### Container Won't Start + +```bash +# Check logs +docker compose logs app + +# Check container status +docker compose ps + +# Rebuild container +docker compose down +docker compose build --no-cache app +docker compose up -d +``` + +### Permission Errors + +```bash +# Fix storage permissions +docker compose exec app chown -R www:www /var/www/storage +docker compose exec app chmod -R 775 /var/www/storage +``` + +### Database Connection Issues + +```bash +# Check if PostgreSQL is running +docker compose ps postgres + +# Check PostgreSQL logs +docker compose logs postgres + +# Test connection +docker compose exec app php artisan tinker +# In tinker: DB::connection()->getPdo(); +``` + +### Queue Not Processing + +```bash +# Check supervisor status +docker compose exec app supervisorctl status + +# Restart queue workers +docker compose exec app supervisorctl restart all + +# Check worker logs +docker compose exec app tail -f storage/logs/worker.log +``` + +# Check kernel module +lsmod | grep wireguard + +# Check WireGuard interface +docker compose exec wireguard wg show +``` + +### Can't Access WireGuard Dashboard + +```bash +# Check if container is running +docker compose ps wireguard + +# Check logs +docker compose logs wireguard + +# Verify port binding +sudo netstat -tulpn | grep 51821 + +# Try accessing via IP +curl http://YOUR_VPS_IP:51821 +``` + +### Can't Access Application After VPN Connection + +```bash +# Verify VPN connection +ping 10.13.13.1 + +# Check routing table (on client) +ip route | grep 10.13.13.0 + +# Check if services are bound correctly +docker compose exec app netstat -tulpn + +# Check nginx logs +docker compose logs nginx + +# Verify nginx is listening on VPN IP +docker compose exec nginx netstat -tulpn | grep 80 + +### SSL Certificate Issues + +```bash +# Check certificate expiry +docker compose run --rm certbot certificates + +# Renew manually +docker compose run --rm certbot renew + +# Check nginx config +docker compose exec nginx nginx -t + +# Reload nginx +docker compose restart nginx +``` + +### WireGuard Not Working + +```bash +# Check WireGuard status +docker compose logs wireguard + +# Restart WireGuard +docker compose restart wireguard + +# Check firewall +sudo ufw status + +# Verify port is open +sudo ss -tulpn | grep 51820 +``` + +### High Memory Usage + +```bash +# ChecWireGuard VPN configured and working +- [ ] WireGuard UI secured (VPN-only after initial setup) +- [ ] All services bound to VPN network (10.13.13.1) +- [ ] Firewall configured (UFW) - only VPN port public +- [ ] SSH key authentication enabled +- [ ] Strong passwords for all services (DB, pgAdmin, Portainer, WireGuard) +- [ ] `.env` file not in git +- [ ] SSL certificates configured (self-signed or Let's Encrypt) +- [ ] Regular backups automated +- [ ] Log rotation configured +- [ ] Security headers in nginx +- [ ] Docker containers run as non-root +- [ ] Regular system updates scheduled +- [ ] WireGuard client configs secur +### Application Returns 500 Error + +```bash +# Check Laravel logs +docker compose exec app tail -f storage/logs/laravel.log + +# Check permissions +docker compose exec app ls -la storage/ + +# Clear caches +docker compose exec app php artisan cache:clear +docker compose exec app php artisan config:clear +docker compose exec app php artisan route:clear +docker compose exec app php artisan view:clear +``` + +--- + +## Security Checklist + +- [ ] Firewall configured (UFW) +- [ ] SSH key authentication enabled +- [ ] Strong database passwords +- [ ] `.env` file not in git +- [ ] Services behind WireGuard VPN +- [ ] SSL certificates valid +- [ ] Regular backups automated +- [ ] Log rotation configured +- [ ] Security headers in nginx +- [ ] Docker containers run as non-root +- [ ] Regular system updates scheduled + +--- + +## Useful Commands Reference + +```bash +# Deploy new version +./deploy.sh + +# View all logs +docker compose logs -f + +# Restart everything +docker compose restart + +# Update single container +docker compose up -d --no-deps --build app + +# Run artisan commands +docker compose exec app php artisan [command] + +# Access container shell +docker compose exec app sh + +# Database backup +docker compose exec postgres pg_dump -U teren_user teren_app > backup.sql + +# System monitoring +htop +docker stats +df -h +``` + +--- + +## Support & Updates + +For issues specific to the Teren App, check the application logs and supervisor status. For Docker/infrastructure issues, check container logs and system logs. + +To update the deployment guide or Docker configuration, submit changes via Gitea and they'll be automatically deployed. + +--- + +**Last Updated:** January 2026 +**Version:** 1.0.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f23c8aa --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LOCAL_TESTING_GUIDE.md b/LOCAL_TESTING_GUIDE.md new file mode 100644 index 0000000..919627a --- /dev/null +++ b/LOCAL_TESTING_GUIDE.md @@ -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 /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) diff --git a/QUICK_START_VPN.md b/QUICK_START_VPN.md new file mode 100644 index 0000000..18541b6 --- /dev/null +++ b/QUICK_START_VPN.md @@ -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. diff --git a/REPORTS_BACKEND_REWORK_PLAN.md b/REPORTS_BACKEND_REWORK_PLAN.md new file mode 100644 index 0000000..950c665 --- /dev/null +++ b/REPORTS_BACKEND_REWORK_PLAN.md @@ -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 diff --git a/REPORTS_FRONTEND_REWORK_PLAN.md b/REPORTS_FRONTEND_REWORK_PLAN.md new file mode 100644 index 0000000..60df866 --- /dev/null +++ b/REPORTS_FRONTEND_REWORK_PLAN.md @@ -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 + + +
+ + Title +
+ Description +
+ + + +
+``` + +**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 `
` + - Use `` + - Structure: + ```vue + + +
+ + {{ report.name }} +
+ {{ report.description }} +
+ + + + + +
+ ``` + +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 + + +
+
+ {{ name }} + {{ description }} +
+
+ + + +
+
+
+
+ ``` + +3. **Wrap filters in Card:** + ```vue + + +
+ + Filtri +
+
+ + +
+ +
+ +
+ + +
+
+
+ ``` + +4. **Wrap DataTableServer in Card:** + ```vue + + + Rezultati + + Skupaj {{ meta?.total || 0 }} {{ meta?.total === 1 ? 'rezultat' : 'rezultatov' }} + + + + + /> + + + ``` + +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 + +
+ + +
+ ``` + +2. **Replace text/number inputs:** + ```vue +
+ + +
+ ``` + +3. **Replace select inputs (user/client):** + ```vue +
+ + +
Nalagam…
+
+ ``` + +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. diff --git a/app/Console/Commands/FixImportMappingEntities.php b/app/Console/Commands/FixImportMappingEntities.php new file mode 100644 index 0000000..ca7ee0e --- /dev/null +++ b/app/Console/Commands/FixImportMappingEntities.php @@ -0,0 +1,156 @@ + '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; + } +} diff --git a/app/Console/Commands/PopulateImportMappingEntities.php b/app/Console/Commands/PopulateImportMappingEntities.php new file mode 100644 index 0000000..914ae17 --- /dev/null +++ b/app/Console/Commands/PopulateImportMappingEntities.php @@ -0,0 +1,113 @@ + '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; + } +} diff --git a/app/Console/Commands/RefreshMaterializedViews.php b/app/Console/Commands/RefreshMaterializedViews.php new file mode 100644 index 0000000..0312a9c --- /dev/null +++ b/app/Console/Commands/RefreshMaterializedViews.php @@ -0,0 +1,57 @@ +info('No materialized views configured.'); + + return self::SUCCESS; + } + + $concurrently = $this->option('concurrently') ? ' CONCURRENTLY' : ''; + + foreach ($views as $view) { + $name = trim((string) $view); + if ($name === '') { + continue; + } + $sql = 'REFRESH MATERIALIZED VIEW'.$concurrently.' '.DB::getPdo()->quote($name); + // PDO::quote wraps with single quotes; for identifiers we need double quotes or no quotes. + // Use a safe fallback: wrap with " if not already quoted + $safe = 'REFRESH MATERIALIZED VIEW'.$concurrently.' "'.str_replace('"', '""', $name).'"'; + try { + DB::statement($safe); + $this->info("Refreshed: {$name}"); + } catch (\Throwable $e) { + $this->error("Failed to refresh {$name}: ".$e->getMessage()); + } + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SimulateImportV2Command.php b/app/Console/Commands/SimulateImportV2Command.php new file mode 100644 index 0000000..be0ded3 --- /dev/null +++ b/app/Console/Commands/SimulateImportV2Command.php @@ -0,0 +1,145 @@ +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}: {$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; + } +} diff --git a/app/Console/Commands/TestImportV2Command.php b/app/Console/Commands/TestImportV2Command.php new file mode 100644 index 0000000..5a94e7f --- /dev/null +++ b/app/Console/Commands/TestImportV2Command.php @@ -0,0 +1,68 @@ +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; + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 73cd1d6..3846641 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -22,6 +22,15 @@ protected function schedule(Schedule $schedule): void '--days' => $days, ])->dailyAt('02:00'); } + + // Optional: refresh configured materialized views for reporting + $views = (array) config('reports.materialized_views', []); + if (! empty($views)) { + $time = (string) (config('reports.refresh_time', '03:00') ?: '03:00'); + $schedule->command('reports:refresh-mviews', [ + '--concurrently' => true, + ])->dailyAt($time); + } } /** diff --git a/app/Helpers/LZStringHelper.php b/app/Helpers/LZStringHelper.php new file mode 100644 index 0000000..186efa3 --- /dev/null +++ b/app/Helpers/LZStringHelper.php @@ -0,0 +1,224 @@ + $getNextValue(0), 'position' => $resetValue, 'index' => 1]; + + for ($i = 0; $i < 3; $i++) { + $dictionary[$i] = chr($i); + } + + $bits = 0; + $maxpower = pow(2, 2); + $power = 1; + + while ($power != $maxpower) { + $resb = $data['val'] & $data['position']; + $data['position'] >>= 1; + + if ($data['position'] == 0) { + $data['position'] = $resetValue; + $data['val'] = $getNextValue($data['index']++); + } + + $bits |= ($resb > 0 ? 1 : 0) * $power; + $power <<= 1; + } + + $next = $bits; + + switch ($next) { + case 0: + $bits = 0; + $maxpower = pow(2, 8); + $power = 1; + + while ($power != $maxpower) { + $resb = $data['val'] & $data['position']; + $data['position'] >>= 1; + + if ($data['position'] == 0) { + $data['position'] = $resetValue; + $data['val'] = $getNextValue($data['index']++); + } + + $bits |= ($resb > 0 ? 1 : 0) * $power; + $power <<= 1; + } + + $c = chr($bits); + break; + + case 1: + $bits = 0; + $maxpower = pow(2, 16); + $power = 1; + + while ($power != $maxpower) { + $resb = $data['val'] & $data['position']; + $data['position'] >>= 1; + + if ($data['position'] == 0) { + $data['position'] = $resetValue; + $data['val'] = $getNextValue($data['index']++); + } + + $bits |= ($resb > 0 ? 1 : 0) * $power; + $power <<= 1; + } + + $c = chr($bits); + break; + + case 2: + return ''; + } + + $dictionary[$dictSize++] = $c; + $w = $c; + $result[] = $c; + + while (true) { + if ($data['index'] > $length) { + return ''; + } + + $bits = 0; + $maxpower = pow(2, $numBits); + $power = 1; + + while ($power != $maxpower) { + $resb = $data['val'] & $data['position']; + $data['position'] >>= 1; + + if ($data['position'] == 0) { + $data['position'] = $resetValue; + $data['val'] = $getNextValue($data['index']++); + } + + $bits |= ($resb > 0 ? 1 : 0) * $power; + $power <<= 1; + } + + $c = $bits; + + switch ($c) { + case 0: + $bits = 0; + $maxpower = pow(2, 8); + $power = 1; + + while ($power != $maxpower) { + $resb = $data['val'] & $data['position']; + $data['position'] >>= 1; + + if ($data['position'] == 0) { + $data['position'] = $resetValue; + $data['val'] = $getNextValue($data['index']++); + } + + $bits |= ($resb > 0 ? 1 : 0) * $power; + $power <<= 1; + } + + $dictionary[$dictSize++] = chr($bits); + $c = $dictSize - 1; + $enlargeIn--; + break; + + case 1: + $bits = 0; + $maxpower = pow(2, 16); + $power = 1; + + while ($power != $maxpower) { + $resb = $data['val'] & $data['position']; + $data['position'] >>= 1; + + if ($data['position'] == 0) { + $data['position'] = $resetValue; + $data['val'] = $getNextValue($data['index']++); + } + + $bits |= ($resb > 0 ? 1 : 0) * $power; + $power <<= 1; + } + + $dictionary[$dictSize++] = chr($bits); + $c = $dictSize - 1; + $enlargeIn--; + break; + + case 2: + return implode('', $result); + } + + if ($enlargeIn == 0) { + $enlargeIn = pow(2, $numBits); + $numBits++; + } + + if (isset($dictionary[$c])) { + $entry = $dictionary[$c]; + } else { + if ($c === $dictSize) { + $entry = $w.$w[0]; + } else { + return null; + } + } + + $result[] = $entry; + + $dictionary[$dictSize++] = $w.$entry[0]; + $enlargeIn--; + + $w = $entry; + + if ($enlargeIn == 0) { + $enlargeIn = pow(2, $numBits); + $numBits++; + } + } + } +} diff --git a/app/Http/Controllers/Admin/PackageController.php b/app/Http/Controllers/Admin/PackageController.php index 834609e..071dc88 100644 --- a/app/Http/Controllers/Admin/PackageController.php +++ b/app/Http/Controllers/Admin/PackageController.php @@ -23,9 +23,19 @@ class PackageController extends Controller { public function index(Request $request): Response { + $perPage = $request->input('per_page') ?? 25; + $packages = Package::query() ->latest('id') - ->paginate(20); + ->paginate($perPage); + + return Inertia::render('Admin/Packages/Index', [ + 'packages' => $packages, + ]); + } + + public function create(Request $request): Response + { // Minimal lookups for create form (active only) $profiles = \App\Models\SmsProfile::query() ->where('active', true) @@ -58,8 +68,7 @@ public function index(Request $request): Response }) ->values(); - return Inertia::render('Admin/Packages/Index', [ - 'packages' => $packages, + return Inertia::render('Admin/Packages/Create', [ 'profiles' => $profiles, 'senders' => $senders, 'templates' => $templates, @@ -290,6 +299,20 @@ public function cancel(Package $package): RedirectResponse return back()->with('success', 'Package canceled'); } + public function destroy(Package $package): RedirectResponse + { + // Allow deletion only for drafts (not yet dispatched) + if ($package->status !== Package::STATUS_DRAFT) { + return back()->with('error', 'Package not in a deletable state.'); + } + + // Remove items first to avoid FK issues + $package->items()->delete(); + $package->delete(); + + return back()->with('success', 'Package deleted'); + } + /** * List contracts for a given segment and include selected phone per person. */ @@ -298,7 +321,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat $request->validate([ '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'], @@ -309,7 +332,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat ]); $segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null; - $perPage = (int) ($request->input('per_page') ?? 25); + $query = Contract::query() ->with([ @@ -376,9 +399,9 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat }); } - $contracts = $query->paginate($perPage); + $contracts = $query->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']; @@ -417,13 +440,7 @@ 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(), - ], + 'data' => $data ]); } diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index b28f856..f727c4f 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -4,66 +4,125 @@ use App\Http\Requests\StoreContractRequest; use App\Http\Requests\UpdateContractRequest; +use App\Models\Client; use App\Models\ClientCase; use App\Models\Contract; use App\Models\Document; +use App\Models\Segment; +use App\Services\Documents\DocumentStreamService; +use App\Services\ReferenceDataCache; use App\Services\Sms\SmsService; use Exception; use Illuminate\Database\QueryException; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Storage; use Inertia\Inertia; class ClientCaseContoller extends Controller { + public function __construct( + protected ReferenceDataCache $referenceCache, + protected DocumentStreamService $documentStream, + protected \App\Services\ClientCaseDataService $caseDataService + ) {} + /** * Display a listing of the resource. */ public function index(ClientCase $clientCase, Request $request) { + $search = $request->input('search'); + $from = $this->normalizeDate($request->input('from')); + $to = $this->normalizeDate($request->input('to')); + $clientFilter = collect(explode(',', (string) $request->input('clients'))) + ->filter() + ->map(fn ($value) => (int) $value) + ->filter(fn ($value) => $value > 0) + ->unique() + ->values(); + + $perPage = $this->resolvePerPage($request); + $query = $clientCase::query() - ->with(['person.client', 'client.person']) - ->where('active', 1) - ->when($request->input('search'), function ($que, $search) { - $que->whereHas('person', function ($q) use ($search) { - $q->where('full_name', 'ilike', '%'.$search.'%'); - }); + ->select('client_cases.*') + ->when($search, function ($que) use ($search) { + $que->join('person', 'person.id', '=', 'client_cases.person_id') + ->where('person.full_name', 'ilike', '%'.$search.'%') + ->groupBy('client_cases.id'); }) + ->where('client_cases.active', 1) + ->leftJoin('contracts', function ($join) { + $join->on('contracts.client_case_id', '=', 'client_cases.id') + ->whereNull('contracts.deleted_at'); + }) + ->leftJoin('contract_segment', function ($join) { + $join->on('contract_segment.contract_id', '=', 'contracts.id') + ->where('contract_segment.active', true); + }) + ->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id') + ->when($clientFilter->isNotEmpty(), function ($que) use ($clientFilter) { + $que->whereIn('client_cases.client_id', $clientFilter->all()); + }) + ->when($from, function ($que) use ($from) { + $que->whereDate('client_cases.created_at', '>=', $from); + }) + ->when($to, function ($que) use ($to) { + $que->whereDate('client_cases.created_at', '<=', $to); + }) + ->groupBy('client_cases.id') ->addSelect([ - // Count of active contracts (a contract is considered active if it has an active pivot in contract_segment) - 'active_contracts_count' => \DB::query() - ->from('contracts') - ->selectRaw('COUNT(*)') - ->whereColumn('contracts.client_case_id', 'client_cases.id') - ->whereNull('contracts.deleted_at') - ->whereExists(function ($q) { - $q->from('contract_segment') - ->whereColumn('contract_segment.contract_id', 'contracts.id') - ->where('contract_segment.active', true); - }), - // Sum of balances for accounts of active contracts - 'active_contracts_balance_sum' => \DB::query() - ->from('contracts') - ->join('accounts', 'accounts.contract_id', '=', 'contracts.id') - ->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)') - ->whereColumn('contracts.client_case_id', 'client_cases.id') - ->whereNull('contracts.deleted_at') - ->whereExists(function ($q) { - $q->from('contract_segment') - ->whereColumn('contract_segment.contract_id', 'contracts.id') - ->where('contract_segment.active', true); - }), + \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'), ]) - ->orderByDesc('created_at'); + ->with(['person.client', 'client.person']) + ->orderByDesc('client_cases.created_at'); return Inertia::render('Cases/Index', [ 'client_cases' => $query - ->paginate($request->integer('perPage', 15), ['*'], 'client-cases-page') + ->paginate($perPage, ['*'], 'clientCasesPage') ->withQueryString(), - 'filters' => $request->only(['search']), + 'filters' => [ + 'search' => $search, + 'from' => $from, + 'to' => $to, + 'clients' => $clientFilter->map(fn ($value) => (string) $value)->all(), + 'perPage' => $perPage, + ], + 'clients' => Client::query() + ->select(['clients.id', 'person.full_name as name']) + ->join('person', 'person.id', '=', 'clients.person_id') + ->orderBy('person.full_name') + ->get() + ->map(fn ($client) => [ + 'id' => (int) $client->id, + 'name' => (string) ($client->name ?? ''), + ]), ]); } + private function resolvePerPage(Request $request): int + { + $allowed = [10, 15, 25, 50, 100]; + + $perPage = (int) $request->integer('perPage', 15); + + return in_array($perPage, $allowed, true) ? $perPage : 15; + } + + private function normalizeDate(?string $value): ?string + { + if (! $value) { + return null; + } + + try { + return Carbon::parse($value)->toDateString(); + } catch (\Throwable) { + return null; + } + } + /** * Show the form for creating a new resource. */ @@ -119,7 +178,7 @@ public function store(Request $request) }); } - return to_route('client.show', $client); + return back()->with('success', 'Client created.')->with('flash_method', 'POST'); } public function storeContract(ClientCase $clientCase, StoreContractRequest $request) @@ -154,7 +213,7 @@ public function storeContract(ClientCase $clientCase, StoreContractRequest $requ // Preserve segment filter if present $segment = request('segment'); - return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]); + return back()->with('success', 'Contract created.')->with('flash_method', 'POST'); } public function updateContract(ClientCase $clientCase, string $uuid, UpdateContractRequest $request) @@ -220,7 +279,7 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr // Preserve segment filter if present $segment = request('segment'); - return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]); + return back()->with('success', 'Contract updated.')->with('flash_method', 'PUT'); } /** @@ -252,6 +311,9 @@ public function storeActivity(ClientCase $clientCase, Request $request) 'action_id' => 'exists:\App\Models\Action,id', 'decision_id' => 'exists:\App\Models\Decision,id', 'contract_uuid' => 'nullable|uuid', + 'contract_uuids' => 'nullable|array', + 'contract_uuids.*' => 'uuid', + 'create_for_all_contracts' => 'nullable|boolean', 'phone_view' => 'nullable|boolean', 'send_auto_mail' => 'sometimes|boolean', 'attachment_document_ids' => 'sometimes|array', @@ -259,61 +321,102 @@ public function storeActivity(ClientCase $clientCase, Request $request) ]); $isPhoneView = $attributes['phone_view'] ?? false; + $createForAll = $attributes['create_for_all_contracts'] ?? false; + $contractUuids = $attributes['contract_uuids'] ?? []; - // Map contract_uuid to contract_id within the same client case, if provided - $contractId = null; - if (! empty($attributes['contract_uuid'])) { + // Determine which contracts to process + $contractIds = []; + if ($createForAll && !empty($contractUuids)) { + // Get all contract IDs from the provided UUIDs + $contracts = Contract::withTrashed() + ->whereIn('uuid', $contractUuids) + ->where('client_case_id', $clientCase->id) + ->get(); + $contractIds = $contracts->pluck('id')->toArray(); + } elseif (!empty($contractUuids) && isset($contractUuids[0])) { + // Single contract mode + $contract = Contract::withTrashed() + ->where('uuid', $contractUuids[0]) + ->where('client_case_id', $clientCase->id) + ->first(); + if ($contract) { + $contractIds = [$contract->id]; + } + } elseif (!empty($attributes['contract_uuid'])) { + // Legacy single contract_uuid support $contract = Contract::withTrashed() ->where('uuid', $attributes['contract_uuid']) ->where('client_case_id', $clientCase->id) ->first(); if ($contract) { - // Archived contracts are allowed: link activity regardless of active flag - $contractId = $contract->id; + $contractIds = [$contract->id]; } } - // Create activity - $row = $clientCase->activities()->create([ - 'due_date' => $attributes['due_date'] ?? null, - 'amount' => $attributes['amount'] ?? null, - 'note' => $attributes['note'] ?? null, - 'action_id' => $attributes['action_id'], - 'decision_id' => $attributes['decision_id'], - 'contract_id' => $contractId, - ]); - - if ($isPhoneView && $contractId) { - $fieldJob = $contract->fieldJobs() - ->whereNull('completed_at') - ->whereNull('cancelled_at') - ->where('assigned_user_id', \Auth::id()) - ->orderByDesc('id') - ->first(); - - if ($fieldJob) { - $fieldJob->update([ - 'added_activity' => true, - 'last_activity' => $row->created_at, - ]); - - } + // If no contracts specified, create a single activity without contract + if (empty($contractIds)) { + $contractIds = [null]; } - logger()->info('Activity successfully inserted', $attributes); + $createdActivities = []; + $sendFlag = (bool) ($attributes['send_auto_mail'] ?? true); + + // Disable auto mail if creating activities for multiple contracts + if ($sendFlag && count($contractIds) > 1) { + $sendFlag = false; + logger()->info('Auto mail disabled: multiple contracts selected', ['contract_count' => count($contractIds)]); + } - // Auto mail dispatch (best-effort) - try { - $sendFlag = (bool) ($attributes['send_auto_mail'] ?? true); - $row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']); - // Filter attachments to those belonging to the selected contract - $attachmentIds = collect($attributes['attachment_document_ids'] ?? []) - ->filter() - ->map(fn ($v) => (int) $v) - ->values(); - $validAttachmentIds = collect(); - if ($attachmentIds->isNotEmpty() && $contractId) { - $validAttachmentIds = Document::query() + foreach ($contractIds as $contractId) { + // Create activity + $row = $clientCase->activities()->create([ + 'due_date' => $attributes['due_date'] ?? null, + 'amount' => $attributes['amount'] ?? null, + 'note' => $attributes['note'] ?? null, + 'action_id' => $attributes['action_id'], + 'decision_id' => $attributes['decision_id'], + 'contract_id' => $contractId, + ]); + + $createdActivities[] = $row; + + if ($isPhoneView && $contractId) { + $contract = Contract::find($contractId); + if ($contract) { + $fieldJob = $contract->fieldJobs() + ->whereNull('completed_at') + ->whereNull('cancelled_at') + ->where('assigned_user_id', \Auth::id()) + ->orderByDesc('id') + ->first(); + + if ($fieldJob) { + $fieldJob->update([ + 'added_activity' => true, + 'last_activity' => $row->created_at, + ]); + } + } + } + + logger()->info('Activity successfully inserted', array_merge($attributes, ['contract_id' => $contractId])); + + // Auto mail dispatch (best-effort) + try { + $row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']); + // Filter attachments to those belonging to the selected contract + $attachmentIds = collect($attributes['attachment_document_ids'] ?? []) + ->filter() + ->map(fn ($v) => (int) $v) + ->values(); + $validAttachmentIds = collect(); + if ($attachmentIds->isNotEmpty() && $contractId) { + $validAttachmentIds = Document::query() + ->where('documentable_type', Contract::class) + ->where('documentable_id', $contractId) + ->whereIn('id', $attachmentIds) + ->pluck('id'); + $validAttachmentIds = Document::query() ->where('documentable_type', Contract::class) ->where('documentable_id', $contractId) ->whereIn('id', $attachmentIds) @@ -324,19 +427,25 @@ public function storeActivity(ClientCase $clientCase, Request $request) ]); if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) { // If template requires contract and user attempted to send, surface a validation message - return back()->with('warning', 'Email not queued: required contract is missing for the selected template.'); + logger()->warning('Email not queued: required contract is missing for the selected template.'); } if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) { - return back()->with('warning', 'Email not queued: no eligible client emails to receive auto mails.'); + logger()->warning('Email not queued: no eligible client emails to receive auto mails.'); } } catch (\Throwable $e) { // Do not fail activity creation due to mailing issues logger()->warning('Auto mail dispatch failed: '.$e->getMessage()); } + } + + $activityCount = count($createdActivities); + $successMessage = $activityCount > 1 + ? "Successfully created {$activityCount} activities!" + : 'Successfully created activity!'; // Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route. // Use 303 to align with Inertia's recommended POST/Redirect/GET behavior. - return back(303)->with('success', 'Successful created!'); + return back(303)->with('success', $successMessage)->with('flash_method', 'POST'); } catch (QueryException $e) { logger()->error('Database error occurred:', ['error' => $e->getMessage()]); @@ -371,7 +480,7 @@ public function deleteContract(ClientCase $clientCase, string $uuid, Request $re // Preserve segment filter if present $segment = request('segment'); - return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]); + return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment])->with('flash_method', 'DELETE'); } public function updateContractSegment(ClientCase $clientCase, string $uuid, Request $request) @@ -409,7 +518,7 @@ public function updateContractSegment(ClientCase $clientCase, string $uuid, Requ } }); - return back()->with('success', 'Contract segment updated.'); + return back()->with('success', 'Contract segment updated.')->with('flash_method', 'PATCH'); } public function patchContractMeta(ClientCase $clientCase, string $uuid, Request $request) @@ -475,7 +584,7 @@ public function attachSegment(ClientCase $clientCase, Request $request) } }); - return back()->with('success', 'Segment attached to case.'); + return back()->with('success', 'Segment attached to case.')->with('flash_method', 'PATCH'); } public function storeDocument(ClientCase $clientCase, Request $request) @@ -529,7 +638,7 @@ public function storeDocument(ClientCase $clientCase, Request $request) \App\Jobs\GenerateDocumentPreview::dispatch($doc->id); } - return back()->with('success', 'Document uploaded.'); + return back()->with('success', 'Document uploaded.')->with('flash_method', 'POST'); } public function updateDocument(ClientCase $clientCase, Document $document, Request $request) @@ -612,7 +721,7 @@ public function updateDocument(ClientCase $clientCase, Document $document, Reque $document->save(); // Refresh documents list on page - return back()->with('success', __('Document updated.')); + return back()->with('success', 'Document updated.')->with('flash_method', 'PUT'); } public function viewDocument(ClientCase $clientCase, Document $document, Request $request) @@ -640,188 +749,7 @@ public function viewDocument(ClientCase $clientCase, Document $document, Request abort(404); } - // Optional: add authz checks here (e.g., policies) - $disk = $document->disk ?: 'public'; - // Normalize relative path (handle legacy 'public/' or 'public\\' prefixes and backslashes on Windows) - $relPath = $document->path ?? ''; - $relPath = str_replace('\\', '/', $relPath); // unify slashes - $relPath = ltrim($relPath, '/'); - if (str_starts_with($relPath, 'public/')) { - $relPath = substr($relPath, 7); - } - - // If a preview exists (e.g., PDF generated for doc/docx), stream that - $previewDisk = config('files.preview_disk', 'public'); - if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) { - $stream = Storage::disk($previewDisk)->readStream($document->preview_path); - if ($stream === false) { - abort(404); - } - - return response()->stream(function () use ($stream) { - fpassthru($stream); - }, 200, [ - 'Content-Type' => $document->preview_mime ?: 'application/pdf', - 'Content-Disposition' => 'inline; filename="'.addslashes(($document->original_name ?: $document->file_name).'.pdf').'"', - 'Cache-Control' => 'private, max-age=0, no-cache', - 'Pragma' => 'no-cache', - ]); - } - - // If it's a DOC/DOCX and no preview yet, queue generation and show 202 Accepted - $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); - if (in_array($ext, ['doc', 'docx'])) { - \App\Jobs\GenerateDocumentPreview::dispatch($document->id); - - return response('Preview is being generated. Please try again shortly.', 202); - } - - // Try multiple path candidates to account for legacy prefixes - $candidates = []; - $candidates[] = $relPath; - // also try raw original (normalized slashes, trimmed) - $raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null; - if ($raw && $raw !== $relPath) { - $candidates[] = $raw; - } - // if path accidentally contains 'storage/' prefix (public symlink), strip it - if (str_starts_with($relPath, 'storage/')) { - $candidates[] = substr($relPath, 8); - } - if ($raw && str_starts_with($raw, 'storage/')) { - $candidates[] = substr($raw, 8); - } - - $existsOnDisk = false; - foreach ($candidates as $cand) { - if (Storage::disk($disk)->exists($cand)) { - $existsOnDisk = true; - $relPath = $cand; - break; - } - } - - if (! $existsOnDisk) { - // Fallback: some legacy files may live directly under public/, attempt to stream from there - $publicFull = public_path($relPath); - $real = @realpath($publicFull); - $publicRoot = @realpath(public_path()); - $realN = $real ? str_replace('\\', '/', $real) : null; - $rootN = $publicRoot ? str_replace('\\', '/', $publicRoot) : null; - if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { - logger()->info('Document view fallback: serving from public path', [ - 'document_id' => $document->id, - 'path' => $realN, - ]); - $fp = @fopen($real, 'rb'); - if ($fp === false) { - abort(404); - } - - return response()->stream(function () use ($fp) { - fpassthru($fp); - }, 200, [ - 'Content-Type' => $document->mime_type ?: 'application/octet-stream', - 'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"', - 'Cache-Control' => 'private, max-age=0, no-cache', - 'Pragma' => 'no-cache', - ]); - } - - logger()->warning('Document view 404: file missing on disk and public fallback failed', [ - 'document_id' => $document->id, - 'document_uuid' => $document->uuid, - 'disk' => $disk, - 'path' => $document->path, - 'normalizedCandidates' => $candidates, - 'public_candidate' => $publicFull, - ]); - abort(404); - } - - $stream = Storage::disk($disk)->readStream($relPath); - if ($stream === false) { - logger()->warning('Document view: readStream failed, attempting fallbacks', [ - 'document_id' => $document->id, - 'disk' => $disk, - 'relPath' => $relPath, - ]); - - $headers = [ - 'Content-Type' => $document->mime_type ?: 'application/octet-stream', - 'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"', - 'Cache-Control' => 'private, max-age=0, no-cache', - 'Pragma' => 'no-cache', - ]; - - // Fallback 1: get() the bytes directly - try { - $bytes = Storage::disk($disk)->get($relPath); - } catch (\Throwable $e) { - $bytes = null; - } - if (! is_null($bytes) && $bytes !== false) { - return response($bytes, 200, $headers); - } - - // Fallback 2: open via absolute path (local driver) - $abs = null; - try { - if (method_exists(Storage::disk($disk), 'path')) { - $abs = Storage::disk($disk)->path($relPath); - } - } catch (\Throwable $e) { - $abs = null; - } - if ($abs && is_file($abs)) { - $fp = @fopen($abs, 'rb'); - if ($fp !== false) { - logger()->info('Document view fallback: serving from absolute storage path', [ - 'document_id' => $document->id, - 'abs' => str_replace('\\\\', '/', (string) realpath($abs)), - ]); - - return response()->stream(function () use ($fp) { - fpassthru($fp); - }, 200, $headers); - } - } - - // Fallback 3: serve from public path if available - $publicFull = public_path($relPath); - $real = @realpath($publicFull); - $publicRoot = @realpath(public_path()); - $realN = $real ? str_replace('\\\\', '/', $real) : null; - $rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null; - if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { - logger()->info('Document view fallback: serving from public path (post-readStream failure)', [ - 'document_id' => $document->id, - 'path' => $realN, - ]); - $fp = @fopen($real, 'rb'); - if ($fp !== false) { - return response()->stream(function () use ($fp) { - fpassthru($fp); - }, 200, $headers); - } - } - - logger()->warning('Document view 404: all fallbacks failed after readStream failure', [ - 'document_id' => $document->id, - 'disk' => $disk, - 'relPath' => $relPath, - ]); - abort(404); - } - - return response()->stream(function () use ($stream) { - fpassthru($stream); - }, 200, [ - 'Content-Type' => $document->mime_type ?: 'application/octet-stream', - 'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"', - 'Cache-Control' => 'private, max-age=0, no-cache', - 'Pragma' => 'no-cache', - ]); + return $this->documentStream->stream($document, inline: true); } public function downloadDocument(ClientCase $clientCase, Document $document, Request $request) @@ -845,163 +773,8 @@ public function downloadDocument(ClientCase $clientCase, Document $document, Req ]); abort(404); } - $disk = $document->disk ?: 'public'; - // Normalize relative path for Windows and legacy prefixes - $relPath = $document->path ?? ''; - $relPath = str_replace('\\', '/', $relPath); - $relPath = ltrim($relPath, '/'); - if (str_starts_with($relPath, 'public/')) { - $relPath = substr($relPath, 7); - } - $candidates = []; - $candidates[] = $relPath; - $raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null; - if ($raw && $raw !== $relPath) { - $candidates[] = $raw; - } - if (str_starts_with($relPath, 'storage/')) { - $candidates[] = substr($relPath, 8); - } - if ($raw && str_starts_with($raw, 'storage/')) { - $candidates[] = substr($raw, 8); - } - - $existsOnDisk = false; - foreach ($candidates as $cand) { - if (Storage::disk($disk)->exists($cand)) { - $existsOnDisk = true; - $relPath = $cand; - break; - } - } - - if (! $existsOnDisk) { - // Fallback to public/ direct path if present - $publicFull = public_path($relPath); - $real = @realpath($publicFull); - $publicRoot = @realpath(public_path()); - $realN = $real ? str_replace('\\', '/', $real) : null; - $rootN = $publicRoot ? str_replace('\\', '/', $publicRoot) : null; - if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { - logger()->info('Document download fallback: serving from public path', [ - 'document_id' => $document->id, - 'path' => $realN, - ]); - $nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME); - $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); - $name = $ext ? ($nameBase.'.'.$ext) : $nameBase; - $fp = @fopen($real, 'rb'); - if ($fp === false) { - abort(404); - } - - return response()->stream(function () use ($fp) { - fpassthru($fp); - }, 200, [ - 'Content-Type' => $document->mime_type ?: 'application/octet-stream', - 'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"', - 'Cache-Control' => 'private, max-age=0, no-cache', - 'Pragma' => 'no-cache', - ]); - } - - logger()->warning('Document download 404: file missing on disk and public fallback failed', [ - 'document_id' => $document->id, - 'document_uuid' => $document->uuid, - 'disk' => $disk, - 'path' => $document->path, - 'normalizedCandidates' => $candidates, - 'public_candidate' => $publicFull, - ]); - abort(404); - } - $nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME); - $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); - $name = $ext ? ($nameBase.'.'.$ext) : $nameBase; - $stream = Storage::disk($disk)->readStream($relPath); - if ($stream === false) { - logger()->warning('Document download: readStream failed, attempting fallbacks', [ - 'document_id' => $document->id, - 'disk' => $disk, - 'relPath' => $relPath, - ]); - - $headers = [ - 'Content-Type' => $document->mime_type ?: 'application/octet-stream', - 'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"', - 'Cache-Control' => 'private, max-age=0, no-cache', - 'Pragma' => 'no-cache', - ]; - - // Fallback 1: get() the bytes directly - try { - $bytes = Storage::disk($disk)->get($relPath); - } catch (\Throwable $e) { - $bytes = null; - } - if (! is_null($bytes) && $bytes !== false) { - return response($bytes, 200, $headers); - } - - // Fallback 2: open via absolute storage path - $abs = null; - try { - if (method_exists(Storage::disk($disk), 'path')) { - $abs = Storage::disk($disk)->path($relPath); - } - } catch (\Throwable $e) { - $abs = null; - } - if ($abs && is_file($abs)) { - $fp = @fopen($abs, 'rb'); - if ($fp !== false) { - logger()->info('Document download fallback: serving from absolute storage path', [ - 'document_id' => $document->id, - 'abs' => str_replace('\\\\', '/', (string) realpath($abs)), - ]); - - return response()->stream(function () use ($fp) { - fpassthru($fp); - }, 200, $headers); - } - } - - // Fallback 3: serve from public path if available - $publicFull = public_path($relPath); - $real = @realpath($publicFull); - $publicRoot = @realpath(public_path()); - $realN = $real ? str_replace('\\\\', '/', $real) : null; - $rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null; - if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { - logger()->info('Document download fallback: serving from public path (post-readStream failure)', [ - 'document_id' => $document->id, - 'path' => $realN, - ]); - $fp = @fopen($real, 'rb'); - if ($fp !== false) { - return response()->stream(function () use ($fp) { - fpassthru($fp); - }, 200, $headers); - } - } - - logger()->warning('Document download 404: all fallbacks failed after readStream failure', [ - 'document_id' => $document->id, - 'disk' => $disk, - 'relPath' => $relPath, - ]); - abort(404); - } - - return response()->stream(function () use ($stream) { - fpassthru($stream); - }, 200, [ - 'Content-Type' => $document->mime_type ?: 'application/octet-stream', - 'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"', - 'Cache-Control' => 'private, max-age=0, no-cache', - 'Pragma' => 'no-cache', - ]); + return $this->documentStream->stream($document, inline: false); } /** @@ -1015,8 +788,7 @@ public function viewContractDocument(Contract $contract, Document $document, Req abort(404); } - // Reuse the existing logic by delegating to a small helper - return $this->streamDocumentForDisk($document, inline: true); + return $this->documentStream->stream($document, inline: true); } /** @@ -1029,138 +801,7 @@ public function downloadContractDocument(Contract $contract, Document $document, abort(404); } - return $this->streamDocumentForDisk($document, inline: false); - } - - /** - * Internal helper to stream a document either inline or as attachment with all Windows/public fallbacks. - */ - protected function streamDocumentForDisk(Document $document, bool $inline = true) - { - $disk = $document->disk ?: 'public'; - $relPath = $document->path ?? ''; - $relPath = str_replace('\\', '/', $relPath); - $relPath = ltrim($relPath, '/'); - if (str_starts_with($relPath, 'public/')) { - $relPath = substr($relPath, 7); - } - - // Previews for DOC/DOCX - $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); - $previewDisk = config('files.preview_disk', 'public'); - if ($inline && in_array($ext, ['doc', 'docx'])) { - if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) { - $stream = Storage::disk($previewDisk)->readStream($document->preview_path); - if ($stream !== false) { - $previewNameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME); - - return response()->stream(function () use ($stream) { - fpassthru($stream); - }, 200, [ - 'Content-Type' => $document->preview_mime ?: 'application/pdf', - 'Content-Disposition' => 'inline; filename="'.addslashes($previewNameBase.'.pdf').'"', - 'Cache-Control' => 'private, max-age=0, no-cache', - 'Pragma' => 'no-cache', - ]); - } - } - \App\Jobs\GenerateDocumentPreview::dispatch($document->id); - - return response('Preview is being generated. Please try again shortly.', 202); - } - - // Try storage candidates - $candidates = [$relPath]; - $raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null; - if ($raw && $raw !== $relPath) { - $candidates[] = $raw; - } - if (str_starts_with($relPath, 'storage/')) { - $candidates[] = substr($relPath, 8); - } - if ($raw && str_starts_with($raw, 'storage/')) { - $candidates[] = substr($raw, 8); - } - - $found = null; - foreach ($candidates as $cand) { - if (Storage::disk($disk)->exists($cand)) { - $found = $cand; - break; - } - } - - $headers = [ - 'Content-Type' => $document->mime_type ?: 'application/octet-stream', - 'Content-Disposition' => ($inline ? 'inline' : 'attachment').'; filename="'.addslashes($document->original_name ?: $document->file_name).'"', - 'Cache-Control' => 'private, max-age=0, no-cache', - 'Pragma' => 'no-cache', - ]; - - if (! $found) { - // public/ fallback - $publicFull = public_path($relPath); - $real = @realpath($publicFull); - $publicRoot = @realpath(public_path()); - $realN = $real ? str_replace('\\\\', '/', $real) : null; - $rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null; - if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { - $fp = @fopen($real, 'rb'); - if ($fp !== false) { - return response()->stream(function () use ($fp) { - fpassthru($fp); - }, 200, $headers); - } - } - abort(404); - } - - $stream = Storage::disk($disk)->readStream($found); - if ($stream !== false) { - return response()->stream(function () use ($stream) { - fpassthru($stream); - }, 200, $headers); - } - - // Fallbacks on readStream failure - try { - $bytes = Storage::disk($disk)->get($found); - if (! is_null($bytes) && $bytes !== false) { - return response($bytes, 200, $headers); - } - } catch (\Throwable $e) { - } - - $abs = null; - try { - if (method_exists(Storage::disk($disk), 'path')) { - $abs = Storage::disk($disk)->path($found); - } - } catch (\Throwable $e) { - $abs = null; - } - if ($abs && is_file($abs)) { - $fp = @fopen($abs, 'rb'); - if ($fp !== false) { - return response()->stream(function () use ($fp) { - fpassthru($fp); - }, 200, $headers); - } - } - - // public/ again as last try - $publicFull = public_path($found); - $real = @realpath($publicFull); - if ($real && is_file($real)) { - $fp = @fopen($real, 'rb'); - if ($fp !== false) { - return response()->stream(function () use ($fp) { - fpassthru($fp); - }, 200, $headers); - } - } - - abort(404); + return $this->documentStream->stream($document, inline: false); } /** @@ -1173,153 +814,40 @@ public function show(ClientCase $clientCase) ])->where('active', 1)->findOrFail($clientCase->id); $types = [ - 'address_types' => \App\Models\Person\AddressType::all(), - 'phone_types' => \App\Models\Person\PhoneType::all(), + 'address_types' => $this->referenceCache->getAddressTypes(), + 'phone_types' => $this->referenceCache->getPhoneTypes(), ]; - // $active = false; - - // Optional segment filter from query string $segmentId = request()->integer('segment'); - - // Determine latest archive (non-reactivate) setting for this context to infer archive segment and related tables - $latestArchiveSetting = \App\Models\ArchiveSetting::query() - ->where('enabled', true) - ->where(function ($q) { - $q->whereNull('reactivate')->orWhere('reactivate', false); - }) - ->orderByDesc('id') - ->first(); - $archiveSegmentId = optional($latestArchiveSetting)->segment_id; // may be null - $relatedArchiveTables = []; - if ($latestArchiveSetting) { - $entities = (array) $latestArchiveSetting->entities; - foreach ($entities as $edef) { - if (isset($edef['related']) && is_array($edef['related'])) { - foreach ($edef['related'] as $rel) { - $relatedArchiveTables[] = $rel; - } - } - } - $relatedArchiveTables = array_values(array_unique($relatedArchiveTables)); - } - - // Prepare contracts and a reference map. - // Only apply active/inactive filtering IF a segment filter is provided. - $contractsQuery = $case->contracts() - // Only select lean columns to avoid oversize JSON / headers (include description for UI display) - ->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at']) - ->with([ - 'type:id,name', - // Use closure for account to avoid ambiguous column names with latestOfMany join - 'account' => function ($q) { - $q->select([ - 'accounts.id', - 'accounts.contract_id', - 'accounts.type_id', - 'accounts.initial_amount', - 'accounts.balance_amount', - 'accounts.promise_date', - 'accounts.created_at', - 'accounts.updated_at', // include updated_at so FE can detect changes & for debugging - ])->orderByDesc('accounts.id'); - }, - 'segments:id,name', - // Eager load objects so newly created objects appear without full reload logic issues - 'objects:id,contract_id,reference,name,description,type,created_at', - ]); - - $contractsQuery->orderByDesc('created_at'); - - if (! empty($segmentId)) { - // Filter to contracts that are in the provided segment and active on pivot - $contractsQuery->whereExists(function ($q) use ($segmentId) { - $q->from('contract_segment') - ->whereColumn('contract_segment.contract_id', 'contracts.id') - ->where('contract_segment.segment_id', $segmentId) - ->where('contract_segment.active', true); - }); - } - - // NOTE: If a case has an extremely large number of contracts this can still be heavy. - // Consider pagination or deferred (Inertia lazy) loading. For now, hard-cap to 500 to prevent - // pathological memory / header growth. Frontend can request more via future endpoint. - $contracts = $contractsQuery->limit(500)->get(); - - // TEMP DEBUG: log what balances are being sent to Inertia (remove once issue resolved) - try { - logger()->info('Show contracts balances', [ - 'case_id' => $case->id, - 'contract_count' => $contracts->count(), - 'contracts' => $contracts->map(fn ($c) => [ - 'id' => $c->id, - 'uuid' => $c->uuid, - 'reference' => $c->reference, - 'account_id' => optional($c->account)->id, - 'initial_amount' => optional($c->account)->initial_amount, - 'balance_amount' => optional($c->account)->balance_amount, - 'account_updated_at' => optional($c->account)->updated_at, - ])->toArray(), - ]); - } catch (\Throwable $e) { - // swallow - } - - $contractRefMap = []; - foreach ($contracts as $c) { - $contractRefMap[$c->id] = $c->reference; - } - - // Merge client case and contract documents into a single array and include contract reference when applicable - $contractIds = $contracts->pluck('id'); - // Include 'uuid' so frontend can build document routes (was causing missing 'document' param error) - // IMPORTANT: If there are no contracts for this case we must NOT return all contract documents from other cases. - if ($contractIds->isEmpty()) { - $contractDocs = collect(); - } else { - $contractDocs = Document::query() - ->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public']) - ->where('documentable_type', Contract::class) - ->whereIn('documentable_id', $contractIds) - ->orderByDesc('created_at') - ->limit(300) // cap to prevent excessive payload; add pagination later if needed - ->get() - ->map(function ($d) use ($contractRefMap) { - $arr = method_exists($d, 'toArray') ? $d->toArray() : (array) $d; - $arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null; - $arr['contract_uuid'] = optional(Contract::withTrashed()->find($d->documentable_id))->uuid; - - return $arr; - }); - } - - $caseDocs = $case->documents() - ->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public']) - ->orderByDesc('created_at') - ->limit(200) - ->get() - ->map(function ($d) use ($case) { - $arr = method_exists($d, 'toArray') ? $d->toArray() : (array) $d; - $arr['client_case_uuid'] = $case->uuid; - - return $arr; - }); - $mergedDocs = $caseDocs - ->concat($contractDocs) - ->sortByDesc('created_at') - ->values(); - - // Resolve current segment for display when filtered $currentSegment = null; if (! empty($segmentId)) { $currentSegment = \App\Models\Segment::query()->select('id', 'name')->find($segmentId); } + // Get contracts using service + $contractsPerPage = request()->integer('contracts_per_page', 10); + $contracts = $this->caseDataService->getContracts($case, $segmentId, $contractsPerPage); + $contractIds = collect($contracts->items())->pluck('id')->all(); + + // Get activities using service + $activitiesPerPage = request()->integer('activities_per_page', 15); + $encodedFilters = request()->input('filter_activities'); + $activities = $this->caseDataService->getActivities($case, $segmentId, $encodedFilters, $contractIds, $activitiesPerPage); + + // Get documents using service + $contractsPerPage = request()->integer('documentsPerPage', 15); + $documents = $this->caseDataService->getDocuments($case, $contractIds, $contractsPerPage); + + // Get archive metadata using service + + $archiveMeta = $this->caseDataService->getArchiveMeta(); + return Inertia::render('Cases/Show', [ 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']))->firstOrFail(), 'client_case' => $case, 'contracts' => $contracts, - // Active document templates for contracts (latest version per slug) + 'documents' => $documents, + ])->with([ 'contract_doc_templates' => \App\Models\DocumentTemplate::query() ->where('active', true) ->where('core_entity', 'contract') @@ -1328,39 +856,10 @@ public function show(ClientCase $clientCase) ->groupBy('slug') ->map(fn ($g) => $g->sortByDesc('version')->first()) ->values(), - 'archive_meta' => [ - 'archive_segment_id' => $archiveSegmentId, - 'related_tables' => $relatedArchiveTables, - ], - 'activities' => tap( - (function () use ($case, $segmentId, $contractIds) { - $q = $case->activities() - ->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name']) - ->orderByDesc('created_at'); - if (! empty($segmentId)) { - // Only activities for filtered contracts or unlinked (contract_id null) - $q->where(function ($qq) use ($contractIds) { - $qq->whereNull('contract_id'); - if ($contractIds->isNotEmpty()) { - $qq->orWhereIn('contract_id', $contractIds); - } - }); - } - - return $q->paginate(20, ['*'], 'activities')->withQueryString(); - })(), - function ($p) { - $p->getCollection()->transform(function ($a) { - $a->setAttribute('user_name', optional($a->user)->name); - - return $a; - }); - } - ), - 'documents' => $mergedDocs, - 'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(), - 'account_types' => \App\Models\AccountType::all(), - // Include decisions with auto-mail metadata and the linked email template entity_types for UI logic + 'archive_meta' => $archiveMeta, + 'activities' => $activities, + 'contract_types' => $this->referenceCache->getContractTypes(), + 'account_types' => $this->referenceCache->getAccountTypes(), 'actions' => \App\Models\Action::query() ->with([ 'decisions' => function ($q) { @@ -1375,7 +874,6 @@ function ($p) { 'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']), 'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']), 'current_segment' => $currentSegment, - // SMS helpers for per-case sending UI 'sms_profiles' => \App\Models\SmsProfile::query() ->select(['id', 'name', 'default_sender_id']) ->where('active', true) @@ -1443,9 +941,7 @@ public function deleteDocument(ClientCase $clientCase, Document $document, Reque $document->delete(); // soft delete - return $request->wantsJson() - ? response()->json(['status' => 'ok']) - : back()->with('success', 'Document deleted.'); + return back()->with('success', 'Document deleted.')->with('flash_method', 'DELETE'); } /** @@ -1462,9 +958,7 @@ public function deleteContractDocument(Contract $contract, Document $document, R $document->delete(); - return $request->wantsJson() - ? response()->json(['status' => 'ok']) - : back()->with('success', 'Document deleted.'); + return back()->with('success', 'Document deleted.')->with('flash_method', 'DELETE'); } /** @@ -1746,7 +1240,7 @@ public function emergencyCreatePerson(ClientCase $clientCase, Request $request) if ($existing && ! $existing->trashed()) { return back()->with('flash', [ 'type' => 'info', - 'message' => 'Person already exists – emergency creation not needed.', + 'message' => 'Person already exists ÔÇô emergency creation not needed.', ]); } @@ -1851,10 +1345,10 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph if (! empty($validated['sender_id'])) { $sender = \App\Models\SmsSender::query()->find($validated['sender_id']); if (! $sender) { - return back()->with('error', 'Izbran pošiljatelj ne obstaja.'); + return back()->with('error', 'Izbran po┼íiljatelj ne obstaja.'); } if ($profile && (int) $sender->profile_id !== (int) $profile->id) { - return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.'); + return back()->with('error', 'Izbran po┼íiljatelj ne pripada izbranemu profilu.'); } } if (! $profile) { @@ -1897,7 +1391,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph } // Create an activity before sending - $activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']); + $activityNote = sprintf('┼át: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']); $activityData = [ 'note' => $activityNote, 'user_id' => optional($request->user())->id, @@ -1935,7 +1429,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph activityId: $activity?->id, ); - return back()->with('success', 'SMS je bil dodan v čakalno vrsto.'); + return back()->with('success', 'SMS je bil dodan v ─Źakalno vrsto.'); } catch (\Throwable $e) { \Log::warning('SMS enqueue failed', [ 'error' => $e->getMessage(), @@ -1943,7 +1437,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph 'phone_id' => $phone_id, ]); - return back()->with('error', 'SMS ni bil dodan v čakalno vrsto.'); + return back()->with('error', 'SMS ni bil dodan v ─Źakalno vrsto.'); } } diff --git a/app/Http/Controllers/ClientController.php b/app/Http/Controllers/ClientController.php index 5f365f5..b96a478 100644 --- a/app/Http/Controllers/ClientController.php +++ b/app/Http/Controllers/ClientController.php @@ -5,6 +5,7 @@ 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; @@ -13,51 +14,44 @@ class ClientController extends Controller { + public function __construct(protected ReferenceDataCache $referenceCache) {} + public function index(Client $client, Request $request) { + $search = $request->input('search'); + $query = $client::query() - ->with('person') - ->when($request->input('search'), function ($que, $search) { - $que->whereHas('person', function ($q) use ($search) { - $q->where('full_name', 'ilike', '%'.$search.'%'); - }); + ->select('clients.*') + ->when($search, function ($que) use ($search) { + $que->join('person', 'person.id', '=', 'clients.person_id') + ->where('person.full_name', 'ilike', '%'.$search.'%') + ->groupBy('clients.id'); }) - ->where('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) { + $join->on('contracts.client_case_id', '=', 'client_cases.id') + ->whereNull('contracts.deleted_at'); + }) + ->leftJoin('contract_segment', function ($join) { + $join->on('contract_segment.contract_id', '=', 'contracts.id') + ->where('contract_segment.active', true); + }) + ->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id') + ->groupBy('clients.id') ->addSelect([ // Number of client cases for this client that have at least one active contract - 'cases_with_active_contracts_count' => DB::query() - ->from('client_cases') - ->join('contracts', 'contracts.client_case_id', '=', 'client_cases.id') - ->selectRaw('COUNT(DISTINCT client_cases.id)') - ->whereColumn('client_cases.client_id', 'clients.id') - ->whereNull('contracts.deleted_at') - ->whereExists(function ($q) { - $q->from('contract_segment') - ->whereColumn('contract_segment.contract_id', 'contracts.id') - ->where('contract_segment.active', true); - }), - // Sum of account balances for active contracts that belong to this client's cases - 'active_contracts_balance_sum' => DB::query() - ->from('contracts') - ->join('accounts', 'accounts.contract_id', '=', 'contracts.id') - ->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)') - ->whereExists(function ($q) { - $q->from('client_cases') - ->whereColumn('client_cases.id', 'contracts.client_case_id') - ->whereColumn('client_cases.client_id', 'clients.id'); - }) - ->whereNull('contracts.deleted_at') - ->whereExists(function ($q) { - $q->from('contract_segment') - ->whereColumn('contract_segment.contract_id', 'contracts.id') - ->where('contract_segment.active', true); - }), + 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'), ]) - ->orderByDesc('created_at'); + ->with('person') + ->orderByDesc('clients.created_at'); return Inertia::render('Client/Index', [ 'clients' => $query - ->paginate($request->integer('perPage', 15)) + ->paginate($request->integer('per_page', 15)) ->withQueryString(), 'filters' => $request->only(['search']), ]); @@ -71,44 +65,37 @@ public function show(Client $client, Request $request) ->findOrFail($client->id); $types = [ - 'address_types' => \App\Models\Person\AddressType::all(), - 'phone_types' => \App\Models\Person\PhoneType::all(), + 'address_types' => $this->referenceCache->getAddressTypes(), + 'phone_types' => $this->referenceCache->getPhoneTypes(), ]; return Inertia::render('Client/Show', [ 'client' => $data, 'client_cases' => $data->clientCases() - ->with(['person', 'client.person']) - ->when($request->input('search'), fn ($que, $search) => $que->whereHas( - 'person', - fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%') - )) + ->select('client_cases.*') + ->when($request->input('search'), function ($que, $search) { + $que->join('person', 'person.id', '=', 'client_cases.person_id') + ->where('person.full_name', 'ilike', '%'.$search.'%') + ->groupBy('client_cases.id'); + }) + ->leftJoin('contracts', function ($join) { + $join->on('contracts.client_case_id', '=', 'client_cases.id') + ->whereNull('contracts.deleted_at'); + }) + ->leftJoin('contract_segment', function ($join) { + $join->on('contract_segment.contract_id', '=', 'contracts.id') + ->where('contract_segment.active', true); + }) + ->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id') + ->groupBy('client_cases.id') ->addSelect([ - 'active_contracts_count' => \DB::query() - ->from('contracts') - ->selectRaw('COUNT(*)') - ->whereColumn('contracts.client_case_id', 'client_cases.id') - ->whereNull('contracts.deleted_at') - ->whereExists(function ($q) { - $q->from('contract_segment') - ->whereColumn('contract_segment.contract_id', 'contracts.id') - ->where('contract_segment.active', true); - }), - 'active_contracts_balance_sum' => \DB::query() - ->from('contracts') - ->join('accounts', 'accounts.contract_id', '=', 'contracts.id') - ->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)') - ->whereColumn('contracts.client_case_id', 'client_cases.id') - ->whereNull('contracts.deleted_at') - ->whereExists(function ($q) { - $q->from('contract_segment') - ->whereColumn('contract_segment.contract_id', 'contracts.id') - ->where('contract_segment.active', true); - }), + \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'), ]) - ->where('active', 1) - ->orderByDesc('created_at') - ->paginate($request->integer('perPage', 15)) + ->with(['person', 'client.person']) + ->where('client_cases.active', 1) + ->orderByDesc('client_cases.created_at') + ->paginate($request->integer('per_page', 15)) ->withQueryString(), 'types' => $types, 'filters' => $request->only(['search']), @@ -126,8 +113,30 @@ public function contracts(Client $client, Request $request) $segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : []; $contractsQuery = \App\Models\Contract::query() - ->whereHas('clientCase', function ($q) use ($client) { - $q->where('client_id', $client->id); + ->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.start_date', 'contracts.client_case_id']) + ->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id') + ->where('client_cases.client_id', $client->id) + ->whereNull('contracts.deleted_at') + ->when($from || $to, function ($q) use ($from, $to) { + if (! empty($from)) { + $q->whereDate('contracts.start_date', '>=', $from); + } + if (! empty($to)) { + $q->whereDate('contracts.start_date', '<=', $to); + } + }) + ->when($search, function ($q) use ($search) { + $q->leftJoin('person', 'person.id', '=', 'client_cases.person_id') + ->where(function ($inner) use ($search) { + $inner->where('contracts.reference', 'ilike', '%'.$search.'%') + ->orWhere('person.full_name', 'ilike', '%'.$search.'%'); + }); + }) + ->when($segmentIds, function ($q) use ($segmentIds) { + $q->whereHas('segments', function ($s) use ($segmentIds) { + $s->whereIn('segments.id', $segmentIds) + ->where('contract_segment.active', true); + }); }) ->with([ 'clientCase:id,uuid,person_id', @@ -138,43 +147,25 @@ public function contracts(Client $client, Request $request) }, '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'); + ->orderByDesc('contracts.start_date'); $segments = \App\Models\Segment::orderBy('name')->get(['id', 'name']); $types = [ - 'address_types' => \App\Models\Person\AddressType::all(), - 'phone_types' => \App\Models\Person\PhoneType::all(), + 'address_types' => $this->referenceCache->getAddressTypes(), + 'phone_types' => $this->referenceCache->getPhoneTypes(), ]; + // Support custom pagination parameter names used by DataTableNew2 + $perPage = $request->integer('contracts_per_page', $request->integer('per_page', 15)); + $pageNumber = $request->integer('contracts_page', $request->integer('page', 1)); + return Inertia::render('Client/Contracts', [ 'client' => $data, - 'contracts' => $contractsQuery->paginate($request->integer('perPage', 20))->withQueryString(), - 'filters' => $request->only(['from', 'to', 'search', 'segments']), + 'contracts' => $contractsQuery + ->paginate($perPage, ['*'], 'contracts_page', $pageNumber) + ->withQueryString(), + 'filters' => $request->only(['from', 'to', 'search', 'segment']), 'segments' => $segments, 'types' => $types, ]); @@ -295,14 +286,14 @@ public function store(Request $request) // \App\Models\Person\PersonAddress::create($address); - return to_route('client'); + return back()->with('success', 'Client created')->with('flash_method', 'POST'); } public function update(Client $client, Request $request) { - return to_route('client.show', $client); + return back()->with('success', 'Client updated')->with('flash_method', 'PUT'); } /** diff --git a/app/Http/Controllers/ContractController.php b/app/Http/Controllers/ContractController.php index 160b03d..3ee69c2 100644 --- a/app/Http/Controllers/ContractController.php +++ b/app/Http/Controllers/ContractController.php @@ -49,7 +49,7 @@ public function store(Request $request) }); } - return to_route('clientCase.show', $clientCase); + return back()->with('success', 'Contract created')->with('flash_method', 'POST'); } public function update(Contract $contract, Request $request) diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index efab421..ec9d766 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -2,15 +2,17 @@ namespace App\Http\Controllers; +use App\Models\Account; use App\Models\Activity; use App\Models\Client; use App\Models\Contract; -use App\Models\Document; // assuming model name Import +// assuming model name Import use App\Models\FieldJob; // if this model exists use App\Models\Import; use App\Models\SmsLog; use App\Models\SmsProfile; use App\Services\Sms\SmsService; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Inertia\Inertia; @@ -21,256 +23,188 @@ class DashboardController extends Controller public function __invoke(SmsService $sms): Response { $today = now()->startOfDay(); - $yesterday = now()->subDay()->startOfDay(); - $staleThreshold = now()->subDays(7); // assumption: stale if no activity in last 7 days + $cacheMinutes = 5; - $clientsTotal = Client::count(); - $clientsNew7d = Client::where('created_at', '>=', now()->subDays(7))->count(); - // FieldJob table does not have a scheduled_at column (schema shows: assigned_at, completed_at, cancelled_at) - // Temporary logic: if scheduled_at ever added we'll use it; otherwise fall back to assigned_at then created_at. - if (Schema::hasColumn('field_jobs', 'scheduled_at')) { - $fieldJobsToday = FieldJob::whereDate('scheduled_at', $today)->count(); - } else { - // Prefer assigned_at when present, otherwise created_at - $fieldJobsToday = FieldJob::whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)->count(); - } - $documentsToday = Document::whereDate('created_at', $today)->count(); - $activeImports = Import::whereIn('status', ['queued', 'processing'])->count(); - $activeContracts = Contract::where('active', 1)->count(); + // Active clients count - cached + $activeClientsCount = Cache::remember('dashboard:active_clients:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () { + return Client::where('active', true)->count(); + }); - // Basic activities deferred list (limit 10) - $activities = Activity::query() - ->with(['clientCase:id,uuid']) - ->latest() - ->limit(10) - ->get(['id', 'note', 'created_at', 'client_case_id', 'contract_id', 'action_id', 'decision_id']) - ->map(fn ($a) => [ - 'id' => $a->id, - 'note' => $a->note, - 'created_at' => $a->created_at, - 'client_case_id' => $a->client_case_id, - 'client_case_uuid' => $a->clientCase?->uuid, - 'contract_id' => $a->contract_id, - 'action_id' => $a->action_id, - 'decision_id' => $a->decision_id, - ]); + // Active contracts count - cached + $activeContractsCount = Cache::remember('dashboard:active_contracts:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () { + return Contract::whereNull('deleted_at')->count(); + }); - // 7-day trends (including today) - $start = now()->subDays(6)->startOfDay(); - $end = now()->endOfDay(); + // Sum of active contracts' account balance - cached + $totalBalance = Cache::remember('dashboard:total_balance:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () { + return Account::whereHas('contract', function ($q) { + $q->whereNull('deleted_at'); + })->sum('balance_amount') ?? 0; + }); - $dateKeys = collect(range(0, 6)) - ->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d')); - - $clientTrendRaw = Client::whereBetween('created_at', [$start, $end]) - ->selectRaw('DATE(created_at) as d, COUNT(*) as c') - ->groupBy('d') - ->pluck('c', 'd'); - $documentTrendRaw = Document::whereBetween('created_at', [$start, $end]) - ->selectRaw('DATE(created_at) as d, COUNT(*) as c') - ->groupBy('d') - ->pluck('c', 'd'); - $fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end]) - ->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c') - ->groupBy('d') - ->pluck('c', 'd'); - $importTrendRaw = Import::whereBetween('created_at', [$start, $end]) - ->selectRaw('DATE(created_at) as d, COUNT(*) as c') - ->groupBy('d') - ->pluck('c', 'd'); - - // Completed field jobs last 7 days - $fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at') - ->whereBetween('completed_at', [$start, $end]) - ->selectRaw('DATE(completed_at) as d, COUNT(*) as c') - ->groupBy('d') - ->pluck('c', 'd'); - - $trends = [ - 'clients_new' => $dateKeys->map(fn ($d) => (int) ($clientTrendRaw[$d] ?? 0))->values(), - 'documents_new' => $dateKeys->map(fn ($d) => (int) ($documentTrendRaw[$d] ?? 0))->values(), - 'field_jobs' => $dateKeys->map(fn ($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(), - 'imports_new' => $dateKeys->map(fn ($d) => (int) ($importTrendRaw[$d] ?? 0))->values(), - 'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(), - 'labels' => $dateKeys, - ]; - - // Stale client cases (no activity in last 7 days) - $staleCases = \App\Models\ClientCase::query() - ->leftJoin('activities', function ($join) { - $join->on('activities.client_case_id', '=', 'client_cases.id') - ->whereNull('activities.deleted_at'); + // Active promises count (not expired or expires today) - cached + $activePromisesCount = Cache::remember('dashboard:active_promises:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) { + return Account::whereHas('contract', function ($q) { + $q->whereNull('deleted_at'); }) - ->selectRaw('client_cases.id, client_cases.uuid, client_cases.client_ref, MAX(activities.created_at) as last_activity_at, client_cases.created_at') - ->groupBy('client_cases.id', 'client_cases.uuid', 'client_cases.client_ref', 'client_cases.created_at') - ->havingRaw('(MAX(activities.created_at) IS NULL OR MAX(activities.created_at) < ?) AND client_cases.created_at < ?', [$staleThreshold, $staleThreshold]) - ->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC') - ->limit(10) - ->get() - ->map(function ($c) { - // Reference point: last activity if exists, else creation. - $reference = $c->last_activity_at ? \Illuminate\Support\Carbon::parse($c->last_activity_at) : $c->created_at; - // Use minute precision to avoid jumping to 1 too early (e.g. created just before midnight). - $minutes = $reference ? max(0, $reference->diffInMinutes(now())) : 0; - $daysFraction = $minutes / 1440; // 60 * 24 - // Provide both fractional and integer versions (integer preserved for backwards compatibility if needed) - $daysInteger = (int) floor($daysFraction); + ->whereNotNull('promise_date') + ->whereDate('promise_date', '>=', $today) + ->count(); + }); - return [ - 'id' => $c->id, - 'uuid' => $c->uuid, - 'client_ref' => $c->client_ref, - 'last_activity_at' => $c->last_activity_at, - 'created_at' => $c->created_at, - 'days_without_activity' => round($daysFraction, 4), // fractional for finer UI decision (<1 day) - 'days_stale' => $daysInteger, // legacy key (integer) - 'has_activity' => (bool) $c->last_activity_at, - ]; - }); + // Activities (limit 10) - cached + $activities = Cache::remember('dashboard:activities', $cacheMinutes * 60, function () { + return Activity::query() + ->with(['clientCase:id,uuid']) + ->latest() + ->limit(10) + ->get(['id', 'note', 'created_at', 'client_case_id', 'contract_id', 'action_id', 'decision_id']) + ->map(fn ($a) => [ + 'id' => $a->id, + 'note' => $a->note, + 'created_at' => $a->created_at, + 'client_case_id' => $a->client_case_id, + 'client_case_uuid' => $a->clientCase?->uuid, + 'contract_id' => $a->contract_id, + 'action_id' => $a->action_id, + 'decision_id' => $a->decision_id, + ]); + }); - // Field jobs assigned today - $fieldJobsAssignedToday = FieldJob::query() - ->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today) - ->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id']) - ->with(['contract' => function ($q) { - $q->select('id', 'uuid', 'reference', 'client_case_id') - ->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']); - }]) - ->latest(DB::raw('COALESCE(assigned_at, created_at)')) - ->limit(15) - ->get() - ->map(function ($fj) { - $contract = $fj->contract; - $segmentId = null; - if ($contract && method_exists($contract, 'segments')) { - // Determine active segment via pivot active flag if present - $activeSeg = $contract->segments->first(); - if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) { - $segmentId = $activeSeg->id; + // 7-day trends for field jobs - cached + $trends = Cache::remember('dashboard:field_jobs_trends:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () { + $start = now()->subDays(6)->startOfDay(); + $end = now()->endOfDay(); + + $dateKeys = collect(range(0, 6)) + ->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d')); + + $fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end]) + ->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c') + ->groupBy('d') + ->pluck('c', 'd'); + + // Completed field jobs last 7 days + $fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at') + ->whereBetween('completed_at', [$start, $end]) + ->selectRaw('DATE(completed_at) as d, COUNT(*) as c') + ->groupBy('d') + ->pluck('c', 'd'); + + return [ + 'field_jobs' => $dateKeys->map(fn ($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(), + 'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(), + 'labels' => $dateKeys, + ]; + }); + + // Field jobs assigned today - cached + $fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) { + return FieldJob::query() + ->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today) + ->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id']) + ->with(['contract' => function ($q) { + $q->select('id', 'uuid', 'reference', 'client_case_id') + ->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']); + }]) + ->latest(DB::raw('COALESCE(assigned_at, created_at)')) + ->limit(15) + ->get() + ->map(function ($fj) { + $contract = $fj->contract; + $segmentId = null; + if ($contract && method_exists($contract, 'segments')) { + $activeSeg = $contract->segments->first(); + if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) { + $segmentId = $activeSeg->id; + } } - } - return [ - 'id' => $fj->id, - 'priority' => $fj->priority, - // Normalize to ISO8601 strings so FE retains timezone & time component - 'assigned_at' => $fj->assigned_at?->toIso8601String(), - 'created_at' => $fj->created_at?->toIso8601String(), - 'contract' => $contract ? [ - 'uuid' => $contract->uuid, - 'reference' => $contract->reference, - 'client_case_uuid' => optional($contract->clientCase)->uuid, - 'person_full_name' => optional(optional($contract->clientCase)->person)->full_name, - 'segment_id' => $segmentId, - ] : null, - ]; - }); + return [ + 'id' => $fj->id, + 'priority' => $fj->priority, + 'assigned_at' => $fj->assigned_at?->toIso8601String(), + 'created_at' => $fj->created_at?->toIso8601String(), + 'contract' => $contract ? [ + 'uuid' => $contract->uuid, + 'reference' => $contract->reference, + 'client_case_uuid' => optional($contract->clientCase)->uuid, + 'person_full_name' => optional(optional($contract->clientCase)->person)->full_name, + 'segment_id' => $segmentId, + ] : null, + ]; + }); + }); - // Imports in progress (queued / processing) - $importsInProgress = Import::query() - ->whereIn('status', ['queued', 'processing']) - ->latest('created_at') - ->limit(10) - ->get(['id', 'uuid', 'file_name', 'status', 'total_rows', 'imported_rows', 'valid_rows', 'invalid_rows', 'started_at']) - ->map(fn ($i) => [ - 'id' => $i->id, - 'uuid' => $i->uuid, - 'file_name' => $i->file_name, - 'status' => $i->status, - 'total_rows' => $i->total_rows, - 'imported_rows' => $i->imported_rows, - 'valid_rows' => $i->valid_rows, - 'invalid_rows' => $i->invalid_rows, - 'progress_pct' => $i->total_rows ? round(($i->imported_rows / max(1, $i->total_rows)) * 100, 1) : null, - 'started_at' => $i->started_at, - ]); - - // Active document templates summary (active versions) - $activeTemplates = \App\Models\DocumentTemplate::query() - ->where('active', true) - ->latest('updated_at') - ->limit(10) - ->get(['id', 'name', 'slug', 'version', 'updated_at']); - - // System health (deferred) - $queueBacklog = Schema::hasTable('jobs') ? DB::table('jobs')->count() : null; - $failedJobs = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : null; + // System health for timestamp $recentActivity = Activity::query()->latest('created_at')->value('created_at'); $lastActivityMinutes = null; if ($recentActivity) { - // diffInMinutes is absolute (non-negative) but guard anyway & cast to int $lastActivityMinutes = (int) max(0, now()->diffInMinutes($recentActivity)); } $systemHealth = [ - 'queue_backlog' => $queueBacklog, - 'failed_jobs' => $failedJobs, 'last_activity_minutes' => $lastActivityMinutes, 'last_activity_iso' => $recentActivity?->toIso8601String(), 'generated_at' => now()->toIso8601String(), ]; - return Inertia::render('Dashboard', [ + return Inertia::render('Dashboard/Index', [ 'kpis' => [ - 'clients_total' => $clientsTotal, - 'clients_new_7d' => $clientsNew7d, - 'field_jobs_today' => $fieldJobsToday, - 'documents_today' => $documentsToday, - 'active_imports' => $activeImports, - 'active_contracts' => $activeContracts, + 'active_clients' => $activeClientsCount, + 'active_contracts' => $activeContractsCount, + 'total_balance' => $totalBalance, + 'active_promises' => $activePromisesCount, ], 'trends' => $trends, - ])->with([ // deferred props (Inertia v2 style) + ])->with([ 'activities' => fn () => $activities, 'systemHealth' => fn () => $systemHealth, - 'staleCases' => fn () => $staleCases, 'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday, - 'importsInProgress' => fn () => $importsInProgress, - 'activeTemplates' => fn () => $activeTemplates, - 'smsStats' => function () use ($sms, $today) { - // Aggregate counts per profile for today - $counts = SmsLog::query() - ->whereDate('created_at', $today) - ->selectRaw('profile_id, status, COUNT(*) as c') - ->groupBy('profile_id', 'status') - ->get() - ->groupBy('profile_id') - ->map(function ($rows) { - $map = [ - 'queued' => 0, - 'sent' => 0, - 'delivered' => 0, - 'failed' => 0, - ]; - foreach ($rows as $r) { - $map[$r->status] = (int) $r->c; + 'smsStats' => function () use ($sms, $today, $cacheMinutes) { + // SMS stats - cached + return Cache::remember('dashboard:sms_stats:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($sms, $today) { + $counts = SmsLog::query() + ->whereDate('created_at', $today) + ->selectRaw('profile_id, status, COUNT(*) as c') + ->groupBy('profile_id', 'status') + ->get() + ->groupBy('profile_id') + ->map(function ($rows) { + $map = [ + 'queued' => 0, + 'sent' => 0, + 'delivered' => 0, + 'failed' => 0, + ]; + foreach ($rows as $r) { + $map[$r->status] = (int) $r->c; + } + $map['total'] = array_sum($map); + + return $map; + }); + + $profiles = SmsProfile::query() + ->orderBy('name') + ->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']); + + return $profiles->map(function (SmsProfile $p) use ($sms, $counts) { + try { + $balance = $sms->getCreditBalance($p); + } catch (\Throwable $e) { + $balance = '—'; } - $map['total'] = array_sum($map); + $c = $counts->get($p->id) ?? ['queued' => 0, 'sent' => 0, 'delivered' => 0, 'failed' => 0, 'total' => 0]; - return $map; - }); - - // Important: include credential fields so provider calls have proper credentials - $profiles = SmsProfile::query() - ->orderBy('name') - ->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']); - - return $profiles->map(function (SmsProfile $p) use ($sms, $counts) { - // Provider balance may fail; guard and present a placeholder. - try { - $balance = $sms->getCreditBalance($p); - } catch (\Throwable $e) { - $balance = '—'; - } - $c = $counts->get($p->id) ?? ['queued' => 0, 'sent' => 0, 'delivered' => 0, 'failed' => 0, 'total' => 0]; - - return [ - 'id' => $p->id, - 'name' => $p->name, - 'active' => (bool) $p->active, - 'balance' => $balance, - 'today' => $c, - ]; - })->values(); + return [ + 'id' => $p->id, + 'name' => $p->name, + 'active' => (bool) $p->active, + 'balance' => $balance, + 'today' => $c, + ]; + })->values(); + }); }, ]); } diff --git a/app/Http/Controllers/FieldJobController.php b/app/Http/Controllers/FieldJobController.php index 89683bf..51aa08c 100644 --- a/app/Http/Controllers/FieldJobController.php +++ b/app/Http/Controllers/FieldJobController.php @@ -25,56 +25,109 @@ public function index(Request $request) optional($setting)->segment_id, ])->filter()->unique()->values(); - $contracts = Contract::query() - ->with(['clientCase.person', 'clientCase.client.person', 'type', 'account']) - ->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) { - $q->whereHas('segments', function ($sq) use ($segmentIds) { - // Relation already filters on active pivots - $sq->whereIn('segments.id', $segmentIds); - }); - }, function ($q) { - // No segments configured on FieldJobSetting -> return none - $q->whereRaw('1 = 0'); - }) - ->latest('id') - ->limit(50) - ->get(); + $search = $request->input('search'); + $searchAssigned = $request->input('search_assigned'); + $assignedUserId = $request->input('assigned_user_id'); + $unassignedClientUuids = $request->input('unassigned_client_uuids'); + $assignedClientUuids = $request->input('assigned_client_uuids'); - // Mirror client onto the contract for simpler frontend access: c.client.person.full_name - $contracts->each(function (Contract $contract): void { - if ($contract->relationLoaded('clientCase') && $contract->clientCase) { - $contract->setRelation('client', $contract->clientCase->client); - } - }); + $unassignedContracts = Contract::query() + ->with(['clientCase.person.addresses', 'clientCase.client.person:id,uuid,full_name', 'type', 'account']) + ->when($segmentIds->isNotEmpty(), fn($q) => + $q->whereHas('segments', fn($rq) => $rq->whereIn('segments.id', $segmentIds)), + fn($q) => $q->whereRaw('1 = 0') + ) + ->when( !empty($search), fn ($q) => + $q->where(fn($sq) => + $sq->where('reference', 'like', "%{$search}%") + ->orWhereHas('clientCase.person', fn($psq) => + $psq->where('full_name', 'ilike', "%{$search}%") + ) + ->orWhereHas('clientCase.person.addresses', fn ($ccpaq) => + $ccpaq->where('address', 'ilike', "%{$search}") + ) + ) + ) + ->when(!empty($unassignedClientUuids) && is_array($unassignedClientUuids), fn ($q) => + $q->whereHas('clientCase.client', fn($cq) => + $cq->whereIn('uuid', $unassignedClientUuids) + ) + ) + ->whereDoesntHave('fieldJobs', fn ($q) => + $q->whereNull('completed_at') + ->whereNull('cancelled_at') + ) + ->latest('id'); - // Build active assignment map keyed by contract uuid for quicker UI checks - $assignments = collect(); - if ($contracts->isNotEmpty()) { - $activeJobs = FieldJob::query() - ->whereIn('contract_id', $contracts->pluck('id')) - ->whereNull('completed_at') - ->whereNull('cancelled_at') - ->with(['assignedUser:id,name', 'user:id,name', 'contract:id,uuid']) - ->get(); + $unassignedClients = $unassignedContracts->get() + ->pluck('clientCase.client') + ->filter() + ->unique('id') + ->values(); + - $assignments = $activeJobs->mapWithKeys(function (FieldJob $job) { - return [ - optional($job->contract)->uuid => [ - 'assigned_to' => $job->assignedUser ? ['id' => $job->assignedUser->id, 'name' => $job->assignedUser->name] : null, - 'assigned_by' => $job->user ? ['id' => $job->user->id, 'name' => $job->user->name] : null, - 'assigned_at' => $job->assigned_at, - ], - ]; - })->filter(); - } + $assignedContracts = Contract::query() + ->with(['clientCase.person.addresses', 'clientCase.client.person:id,uuid,full_name', 'type', 'account', 'lastFieldJobs', 'lastFieldJobs.assignedUser', 'lastFieldJobs.user']) + ->when($segmentIds->isNotEmpty(), fn($q) => + $q->whereHas('segments', fn($rq) => $rq->whereIn('segments.id', $segmentIds)), + fn($q) => $q->whereRaw('1 = 0') + ) + ->when( !empty($searchAssigned), fn ($q) => + $q->where(fn($sq) => + $sq->where('reference', 'like', "%{$searchAssigned}%") + ->orWhereHas('clientCase.person', fn($psq) => + $psq->where('full_name', 'ilike', "%{$searchAssigned}%") + ) + ->orWhereHas('clientCase.person.addresses', fn ($ccpaq) => + $ccpaq->where('address', 'ilike', "%{$searchAssigned}") + ) + ) + ) + ->when(!empty($assignedClientUuids) && is_array($assignedClientUuids), fn ($q) => + $q->whereHas('clientCase.client', fn($cq) => + $cq->whereIn('uuid', $assignedClientUuids) + ) + ) + ->whereHas('lastFieldJobs', fn ($q) => + $q->whereNull('completed_at') + ->whereNull('cancelled_at') + ->when($assignedUserId && $assignedUserId !== 'all', fn ($jq) => + $jq->where('assigned_user_id', $assignedUserId)) + ) + ->latest('id'); + + $assignedClients = $assignedContracts->get() + ->pluck('clientCase.client') + ->filter() + ->unique('id') + ->values(); $users = User::query()->orderBy('name')->get(['id', 'name']); return Inertia::render('FieldJob/Index', [ 'setting' => $setting, - 'contracts' => $contracts, + 'unassignedContracts' => $unassignedContracts->paginate( + $request->input('per_page_contracts', 10), + ['*'], + 'page_contracts', + $request->input('page_contracts', 1) + ), + 'assignedContracts' => $assignedContracts->paginate( + $request->input('per_page_assignments', 10), + ['*'], + 'page_assignments', + $request->input('page_assignments', 1) + ), + 'unassignedClients' => $unassignedClients, + 'assignedClients' => $assignedClients, 'users' => $users, - 'assignments' => $assignments, + 'filters' => [ + 'search' => $search, + 'search_assigned' => $searchAssigned, + 'assigned_user_id' => $assignedUserId, + 'unassigned_client_uuids' => $unassignedClientUuids, + 'assigned_client_uuids' => $assignedClientUuids, + ], ]); } diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index 455cc39..689a872 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -9,6 +9,8 @@ use App\Models\ImportEvent; use App\Models\ImportTemplate; use App\Services\CsvImportService; +use App\Services\Import\ImportServiceV2; +use App\Services\Import\ImportSimulationServiceV2; use App\Services\ImportProcessor; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -21,14 +23,35 @@ class ImportController extends Controller // List imports (paginated) public function index(Request $request) { - $paginator = Import::query() + $query = Import::query() ->with([ 'client:id,uuid,person_id', 'client.person:id,uuid,full_name', 'template:id,name', ]) - ->orderByDesc('created_at') - ->paginate(15); + ->orderByDesc('created_at'); + + // Apply search filter + if ($search = $request->input('search')) { + $query->where(function ($q) use ($search) { + $q->where('original_name', 'LIKE', "%{$search}%") + ->orWhere('status', 'LIKE', "%{$search}%") + ->orWhereHas('client.person', function ($q) use ($search) { + $q->where('full_name', 'LIKE', "%{$search}%"); + }) + ->orWhereHas('template', function ($q) use ($search) { + $q->where('name', 'LIKE', "%{$search}%"); + }); + }); + } + + // Get per_page from request, default to 25 + $perPage = (int) $request->input('per_page', 25); + if ($perPage < 1 || $perPage > 100) { + $perPage = 25; + } + + $paginator = $query->paginate($perPage); $imports = [ 'data' => $paginator->items(), @@ -164,9 +187,24 @@ public function store(Request $request) public function process(Import $import, Request $request, ImportProcessor $processor) { $import->update(['status' => 'validating', 'started_at' => now()]); - $result = $processor->process($import, user: $request->user()); - - return response()->json($result); + + try { + $result = $processor->process($import, user: $request->user()); + return response()->json($result); + } catch (\Throwable $e) { + \Log::error('Import processing failed', [ + 'import_id' => $import->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $import->update(['status' => 'failed']); + + return response()->json([ + 'success' => false, + 'message' => 'Import processing failed: ' . $e->getMessage(), + ], 500); + } } // Analyze the uploaded file and return column headers or positional indices @@ -405,7 +443,7 @@ public function missingContracts(Import $import) // Query active, non-archived contracts for this client that were not in import // Include person full_name (owner of the client case) and aggregate active accounts' balance_amount - $contractsQ = \App\Models\Contract::query() + $contractsQ = Contract::query() ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') ->join('person', 'person.id', '=', 'client_cases.person_id') ->leftJoin('accounts', function ($join) { @@ -493,7 +531,7 @@ public function getEvents(Import $import) public function missingKeyrefRows(Import $import) { // Identify row IDs from events. Prefer specific event key, fallback to message pattern - $rowIds = \App\Models\ImportEvent::query() + $rowIds = ImportEvent::query() ->where('import_id', $import->id) ->where(function ($q) { $q->where('event', 'contract_keyref_not_found') @@ -673,6 +711,10 @@ 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. + * + * @param Import $import + * @param Request $request + * @return \Illuminate\Http\JsonResponse */ public function simulate(Import $import, Request $request) { @@ -683,7 +725,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 +827,6 @@ public function destroy(Request $request, Import $import) $import->delete(); - return back()->with(['ok' => true]); + return back()->with('success', 'Import deleted successfully'); } } diff --git a/app/Http/Controllers/ImportTemplateController.php b/app/Http/Controllers/ImportTemplateController.php index 3baf08a..9341f8b 100644 --- a/app/Http/Controllers/ImportTemplateController.php +++ b/app/Http/Controllers/ImportTemplateController.php @@ -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, ]); } @@ -547,6 +561,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import 'options' => 'nullable|array', 'position' => 'nullable|integer', ])->validate(); + $mapping->update([ 'source_column' => $data['source_column'], 'entity' => $data['entity'] ?? null, @@ -557,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 @@ -643,6 +657,7 @@ public function applyToImport(Request $request, ImportTemplate $template, Import $import->update([ 'import_template_id' => $template->id, + 'reactivate' => $template->reactivate, 'meta' => $merged, ]); }); @@ -664,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'); + } } diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php index 88afccd..ad88088 100644 --- a/app/Http/Controllers/NotificationController.php +++ b/app/Http/Controllers/NotificationController.php @@ -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; diff --git a/app/Http/Controllers/PersonController.php b/app/Http/Controllers/PersonController.php index 05d1e08..c2682e3 100644 --- a/app/Http/Controllers/PersonController.php +++ b/app/Http/Controllers/PersonController.php @@ -26,18 +26,10 @@ public function update(Person $person, Request $request) $person->update($attributes); - if ($request->header('X-Inertia')) { - return back()->with('success', 'Person updated'); - } + return back()->with('success', 'Person updated')->with('flash_method', 'PUT'); + - return response()->json([ - 'person' => [ - 'full_name' => $person->full_name, - 'tax_number' => $person->tax_number, - 'social_security_number' => $person->social_security_number, - 'description' => $person->description, - ], - ]); + } public function createAddress(Person $person, Request $request) @@ -60,13 +52,8 @@ public function createAddress(Person $person, Request $request) ], $attributes); // Support Inertia form submissions (redirect back) and JSON (for API/axios) - if ($request->header('X-Inertia')) { - return back()->with('success', 'Address created'); - } + return back()->with('success', 'Address created')->with('flash_method', 'POST'); - return response()->json([ - 'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id), - ]); } public function updateAddress(Person $person, int $address_id, Request $request) @@ -84,13 +71,8 @@ public function updateAddress(Person $person, int $address_id, Request $request) $address->update($attributes); - if ($request->header('X-Inertia')) { - return back()->with('success', 'Address updated'); - } - - return response()->json([ - 'address' => $address, - ]); + return back()->with('success', 'Address updated')->with('flash_method', 'PUT'); + } public function deleteAddress(Person $person, int $address_id, Request $request) @@ -98,11 +80,8 @@ public function deleteAddress(Person $person, int $address_id, Request $request) $address = $person->addresses()->findOrFail($address_id); $address->delete(); // soft delete - if ($request->header('X-Inertia')) { - return back()->with('success', 'Address deleted'); - } - return response()->json(['status' => 'ok']); + return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE'); } public function createPhone(Person $person, Request $request) @@ -122,7 +101,7 @@ public function createPhone(Person $person, Request $request) 'country_code' => $attributes['country_code'] ?? null, ], $attributes); - return back()->with('success', 'Phone added successfully'); + return back()->with('success', 'Phone added successfully')->with('flash_method', 'POST'); } public function updatePhone(Person $person, int $phone_id, Request $request) @@ -140,7 +119,7 @@ public function updatePhone(Person $person, int $phone_id, Request $request) $phone->update($attributes); - return back()->with('success', 'Phone updated successfully'); + return back()->with('success', 'Phone updated successfully')->with('flash_method', 'PUT'); } public function deletePhone(Person $person, int $phone_id, Request $request) @@ -148,7 +127,7 @@ public function deletePhone(Person $person, int $phone_id, Request $request) $phone = $person->phones()->findOrFail($phone_id); $phone->delete(); // soft delete - return back()->with('success', 'Phone deleted'); + return back()->with('success', 'Phone deleted')->with('flash_method', 'DELETE'); } public function createEmail(Person $person, Request $request) @@ -170,7 +149,7 @@ public function createEmail(Person $person, Request $request) 'value' => $attributes['value'], ], $attributes); - return back()->with('success', 'Email added successfully'); + return back()->with('success', 'Email added successfully')->with('flash_method', 'POST'); } public function updateEmail(Person $person, int $email_id, Request $request) @@ -191,7 +170,7 @@ public function updateEmail(Person $person, int $email_id, Request $request) $email->update($attributes); - return back()->with('success', 'Email updated successfully'); + return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT'); } public function deleteEmail(Person $person, int $email_id, Request $request) @@ -203,7 +182,7 @@ public function deleteEmail(Person $person, int $email_id, Request $request) return back()->with('success', 'Email deleted'); } - return response()->json(['status' => 'ok']); + return back()->with('success', 'Email deleted')->with('flash_method', 'DELETE'); } // TRR (bank account) CRUD @@ -225,13 +204,10 @@ 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); - if ($request->header('X-Inertia')) { - return back()->with('success', 'TRR added successfully'); - } + + return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST'); + - return response()->json([ - 'trr' => BankAccount::findOrFail($trr->id), - ]); } public function updateTrr(Person $person, int $trr_id, Request $request) @@ -253,13 +229,8 @@ public function updateTrr(Person $person, int $trr_id, Request $request) $trr = $person->bankAccounts()->findOrFail($trr_id); $trr->update($attributes); - if ($request->header('X-Inertia')) { - return back()->with('success', 'TRR updated successfully'); - } + return back()->with('success', 'TRR updated successfully')->with('flash_method', 'PUT'); - return response()->json([ - 'trr' => $trr, - ]); } public function deleteTrr(Person $person, int $trr_id, Request $request) @@ -267,10 +238,8 @@ public function deleteTrr(Person $person, int $trr_id, Request $request) $trr = $person->bankAccounts()->findOrFail($trr_id); $trr->delete(); - if ($request->header('X-Inertia')) { - return back()->with('success', 'TRR deleted'); - } - - return response()->json(['status' => 'ok']); + + return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE'); + } } diff --git a/app/Http/Controllers/PhoneViewController.php b/app/Http/Controllers/PhoneViewController.php index 14d3a13..25df4d4 100644 --- a/app/Http/Controllers/PhoneViewController.php +++ b/app/Http/Controllers/PhoneViewController.php @@ -3,16 +3,22 @@ namespace App\Http\Controllers; use App\Models\FieldJob; +use App\Services\ReferenceDataCache; use Illuminate\Http\Request; use Inertia\Inertia; class PhoneViewController extends Controller { + public function __construct(protected ReferenceDataCache $referenceCache) {} public function index(Request $request) { $userId = $request->user()->id; + $search = $request->input('search'); + $clientFilter = $request->input('client'); + $perPage = $request->integer('per_page', 15); + $perPage = max(1, min(100, $perPage)); - $jobs = FieldJob::query() + $query = FieldJob::query() ->where('assigned_user_id', $userId) ->whereNull('completed_at') ->whereNull('cancelled_at') @@ -21,32 +27,78 @@ public function index(Request $request) $q->with([ 'type:id,name', 'account', - 'clientCase.person' => function ($pq) { - $pq->with(['addresses', 'phones']); - }, + 'clientCase.person.address.type', + 'clientCase.person.phones', 'clientCase.client:id,uuid,person_id', 'clientCase.client.person:id,full_name', ]); }, ]) - ->orderByDesc('assigned_at') - ->limit(100) - ->get(); + ->orderByDesc('assigned_at'); + + // Apply client filter + if ($clientFilter) { + $query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) { + $q->where('uuid', $clientFilter); + }); + } + + // Apply search filter + if ($search) { + $query->where(function ($q) use ($search) { + $q->whereHas('contract', function ($cq) use ($search) { + $cq->where('reference', 'ilike', '%'.$search.'%') + ->orWhereHas('clientCase.person', function ($pq) use ($search) { + $pq->where('full_name', 'ilike', '%'.$search.'%'); + }) + ->orWhereHas('clientCase.client.person', function ($pq) use ($search) { + $pq->where('full_name', 'ilike', '%'.$search.'%'); + }); + }); + }); + } + + $jobs = $query->paginate($perPage)->withQueryString(); + + // Get unique clients for filter dropdown + $clients = \App\Models\Client::query() + ->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) { + $q->where('assigned_user_id', $userId) + ->whereNull('completed_at') + ->whereNull('cancelled_at'); + }) + ->with(['person:id,full_name']) + ->get(['uuid', 'person_id']) + ->map(fn ($c) => [ + 'uuid' => (string) $c->uuid, + 'name' => (string) optional($c->person)->full_name, + ]) + ->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE) + ->values(); return Inertia::render('Phone/Index', [ 'jobs' => $jobs, + 'clients' => $clients, 'view_mode' => 'assigned', + 'filters' => [ + 'search' => $search, + 'client' => $clientFilter, + ], ]); } public function completedToday(Request $request) { $userId = $request->user()->id; + $search = $request->input('search'); + $clientFilter = $request->input('client'); + $perPage = $request->integer('per_page', 15); + $perPage = max(1, min(100, $perPage)); $start = now()->startOfDay(); $end = now()->endOfDay(); - $jobs = FieldJob::query() + $query = FieldJob::query() ->where('assigned_user_id', $userId) ->whereNull('cancelled_at') ->whereBetween('completed_at', [$start, $end]) @@ -55,21 +107,63 @@ public function completedToday(Request $request) $q->with([ 'type:id,name', 'account', - 'clientCase.person' => function ($pq) { - $pq->with(['addresses', 'phones']); - }, + 'clientCase.person.address.type', + 'clientCase.person.phones', 'clientCase.client:id,uuid,person_id', 'clientCase.client.person:id,full_name', ]); }, ]) - ->orderByDesc('completed_at') - ->limit(100) - ->get(); + ->orderByDesc('completed_at'); + + // Apply client filter + if ($clientFilter) { + $query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) { + $q->where('uuid', $clientFilter); + }); + } + + // Apply search filter + if ($search) { + $query->where(function ($q) use ($search) { + $q->whereHas('contract', function ($cq) use ($search) { + $cq->where('reference', 'ilike', '%'.$search.'%') + ->orWhereHas('clientCase.person', function ($pq) use ($search) { + $pq->where('full_name', 'ilike', '%'.$search.'%'); + }) + ->orWhereHas('clientCase.client.person', function ($pq) use ($search) { + $pq->where('full_name', 'ilike', '%'.$search.'%'); + }); + }); + }); + } + + $jobs = $query->paginate($perPage)->withQueryString(); + + // Get unique clients for filter dropdown + $clients = \App\Models\Client::query() + ->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) { + $q->where('assigned_user_id', $userId) + ->whereNull('cancelled_at') + ->whereBetween('completed_at', [$start, $end]); + }) + ->with(['person:id,full_name']) + ->get(['uuid', 'person_id']) + ->map(fn ($c) => [ + 'uuid' => (string) $c->uuid, + 'name' => (string) optional($c->person)->full_name, + ]) + ->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE) + ->values(); return Inertia::render('Phone/Index', [ 'jobs' => $jobs, + 'clients' => $clients, 'view_mode' => 'completed-today', + 'filters' => [ + 'search' => $search, + 'client' => $clientFilter, + ], ]); } @@ -79,7 +173,7 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request) $completedMode = $request->boolean('completed'); // Eager load case with person details - $case = $clientCase->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts'); + $case = $clientCase->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts'); // Query contracts based on field jobs $contractsQuery = FieldJob::query() @@ -129,7 +223,7 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request) ->unique(); return Inertia::render('Phone/Case/Index', [ - 'client' => $case->client->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts'), + 'client' => $case->client->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts'), 'client_case' => $case, 'contracts' => $contracts, 'documents' => $documents, diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php new file mode 100644 index 0000000..a4e3f97 --- /dev/null +++ b/app/Http/Controllers/ReportController.php @@ -0,0 +1,423 @@ +orderBy('order') + ->orderBy('name') + ->get() + ->map(fn ($r) => [ + 'slug' => $r->slug, + 'name' => $r->name, + 'description' => $r->description, + 'category' => $r->category, + ]) + ->values(); + + return Inertia::render('Reports/Index', [ + 'reports' => $reports, + ]); + } + + public function show(string $slug, Request $request) + { + $report = Report::with(['filters', 'columns']) + ->where('slug', $slug) + ->where('enabled', true) + ->firstOrFail(); + + // Accept filters & pagination from query and return initial data for server-driven table + $inputs = $this->buildInputsArray($report); + $filters = $this->validateFilters($inputs, $request); + \Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]); + + $perPage = (int) ($request->integer('per_page') ?: 25); + $query = $this->queryBuilder->build($report, $filters); + $paginator = $query->paginate($perPage); + + $rows = collect($paginator->items()) + ->map(fn ($row) => $this->normalizeRow($row)) + ->values(); + + return Inertia::render('Reports/Show', [ + 'slug' => $report->slug, + 'name' => $report->name, + 'description' => $report->description, + 'inputs' => $inputs, + 'columns' => $this->buildColumnsArray($report), + 'rows' => $rows, + 'meta' => [ + 'total' => $paginator->total(), + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'last_page' => $paginator->lastPage(), + ], + 'query' => array_filter($filters, fn ($v) => $v !== null && $v !== ''), + ]); + } + + public function data(string $slug, Request $request) + { + $report = Report::with(['filters', 'columns']) + ->where('slug', $slug) + ->where('enabled', true) + ->firstOrFail(); + + $inputs = $this->buildInputsArray($report); + $filters = $this->validateFilters($inputs, $request); + $perPage = (int) ($request->integer('per_page') ?: 25); + + $query = $this->queryBuilder->build($report, $filters); + $paginator = $query->paginate($perPage); + + $rows = collect($paginator->items()) + ->map(fn ($row) => $this->normalizeRow($row)) + ->values(); + + return response()->json([ + 'data' => $rows, + 'total' => $paginator->total(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ]); + } + + public function export(string $slug, Request $request) + { + $report = Report::with(['filters', 'columns']) + ->where('slug', $slug) + ->where('enabled', true) + ->firstOrFail(); + + $inputs = $this->buildInputsArray($report); + $filters = $this->validateFilters($inputs, $request); + $format = strtolower((string) $request->get('format', 'csv')); + + $query = $this->queryBuilder->build($report, $filters); + $rows = $query->get()->map(fn ($row) => $this->normalizeRow($row)); + $columns = $this->buildColumnsArray($report); + $filename = $report->slug.'-'.now()->format('Ymd_His'); + + if ($format === 'pdf') { + $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [ + 'name' => $report->name, + 'columns' => $columns, + 'rows' => $rows, + ]); + + return $pdf->download($filename.'.pdf'); + } + + if ($format === 'xlsx') { + $keys = array_map(fn ($c) => $c['key'], $columns); + $headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns); + + // Convert values for correct Excel rendering (dates, numbers, text) + $array = $this->prepareXlsxArray($rows, $keys); + + // Build base column formats: text for contracts, EU datetime for *_at; numbers are formatted per-cell in AfterSheet + $columnFormats = []; + $textColumns = []; + $dateColumns = []; + foreach ($keys as $i => $key) { + $letter = $this->excelColumnLetter($i + 1); + if ($key === 'contract_reference') { + $columnFormats[$letter] = '@'; + $textColumns[] = $letter; + + continue; + } + if (str_ends_with($key, '_at')) { + $columnFormats[$letter] = 'dd.mm.yyyy hh:mm'; + $dateColumns[] = $letter; + + continue; + } + } + + // Anonymous export with custom value binder to force text where needed + $export = new class($array, $headings, $columnFormats, $textColumns, $dateColumns) extends \Maatwebsite\Excel\DefaultValueBinder implements \Maatwebsite\Excel\Concerns\FromArray, \Maatwebsite\Excel\Concerns\ShouldAutoSize, \Maatwebsite\Excel\Concerns\WithColumnFormatting, \Maatwebsite\Excel\Concerns\WithCustomValueBinder, \Maatwebsite\Excel\Concerns\WithEvents, \Maatwebsite\Excel\Concerns\WithHeadings + { + public function __construct(private array $array, private array $headings, private array $formats, private array $textColumns, private array $dateColumns) {} + + public function array(): array + { + return $this->array; + } + + public function headings(): array + { + return $this->headings; + } + + public function columnFormats(): array + { + return $this->formats; + } + + public function bindValue(\PhpOffice\PhpSpreadsheet\Cell\Cell $cell, $value): bool + { + $col = preg_replace('/\d+/', '', $cell->getCoordinate()); // e.g., B from B2 + // Force text for configured columns or very long digit-only strings (>15) + if (in_array($col, $this->textColumns, true) || (is_string($value) && ctype_digit($value) && strlen($value) > 15)) { + $cell->setValueExplicit((string) $value, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING); + + return true; + } + + return parent::bindValue($cell, $value); + } + + public function registerEvents(): array + { + return [ + \Maatwebsite\Excel\Events\AfterSheet::class => function (\Maatwebsite\Excel\Events\AfterSheet $event) { + $sheet = $event->sheet->getDelegate(); + // Data starts at row 2 (row 1 is headings) + $rowIndex = 2; + foreach ($this->array as $row) { + foreach (array_values($row) as $i => $val) { + $colLetter = $this->colLetter($i + 1); + if (in_array($colLetter, $this->textColumns, true) || in_array($colLetter, $this->dateColumns, true)) { + continue; // already handled via columnFormats or binder + } + $coord = $colLetter.$rowIndex; + $fmt = null; + if (is_int($val)) { + // Integer: thousands separator, no decimals + $fmt = '#,##0'; + } elseif (is_float($val)) { + // Float: show decimals only if fractional part exists + $fmt = (floor($val) != $val) ? '#,##0.00' : '#,##0'; + } + if ($fmt) { + $sheet->getStyle($coord)->getNumberFormat()->setFormatCode($fmt); + } + } + $rowIndex++; + } + }, + ]; + } + + private function colLetter(int $index): string + { + $letter = ''; + while ($index > 0) { + $mod = ($index - 1) % 26; + $letter = chr(65 + $mod).$letter; + $index = intdiv($index - $mod, 26) - 1; + } + + return $letter; + } + }; + + return \Maatwebsite\Excel\Facades\Excel::download($export, $filename.'.xlsx'); + } + + // Default CSV export + $keys = array_map(fn ($c) => $c['key'], $columns); + $headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns); + + $csv = fopen('php://temp', 'r+'); + fputcsv($csv, $headings); + foreach ($rows as $r) { + $line = collect($keys)->map(fn ($k) => data_get($r, $k))->toArray(); + fputcsv($csv, $line); + } + rewind($csv); + $content = stream_get_contents($csv) ?: ''; + fclose($csv); + + return response($content, 200, [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="'.$filename.'.csv"', + ]); + } + + /** + * Lightweight users lookup for filters: id + name, optional search and limit. + */ + public function users(Request $request) + { + $search = trim((string) $request->get('search', '')); + $limit = (int) ($request->integer('limit') ?: 10); + + $q = \App\Models\User::query()->orderBy('name'); + if ($search !== '') { + $like = '%'.mb_strtolower($search).'%'; + $q->where(function ($qq) use ($like) { + $qq->whereRaw('LOWER(name) LIKE ?', [$like]) + ->orWhereRaw('LOWER(email) LIKE ?', [$like]); + }); + } + + $users = $q->limit(max(1, min(50, $limit)))->get(['id', 'name']); + + return response()->json($users); + } + + /** + * Lightweight clients lookup for filters: uuid + name (person full_name), optional search and limit. + */ + public function clients(Request $request) + { + $clients = \App\Models\Client::query() + ->with('person:id,full_name') + ->get() + ->map(fn($c) => [ + 'id' => $c->uuid, + 'name' => $c->person->full_name ?? 'Unknown' + ]) + ->sortBy('name') + ->values(); + + return response()->json($clients); + } + + /** + * Build validation rules based on inputs descriptor and validate. + * + * @param array> $inputs + * @return array + */ + protected function validateFilters(array $inputs, Request $request): array + { + $rules = []; + foreach ($inputs as $inp) { + $key = $inp['key']; + $type = $inp['type'] ?? 'string'; + $nullable = ($inp['nullable'] ?? true) ? 'nullable' : 'required'; + $rules[$key] = match ($type) { + 'date' => [$nullable, 'date'], + 'integer' => [$nullable, 'integer'], + 'select:user' => [$nullable, 'integer', 'exists:users,id'], + 'select:client' => [$nullable, 'string', 'exists:clients,uuid'], + default => [$nullable, 'string'], + }; + } + + return $request->validate($rules); + } + + /** + * Build inputs array from report filters. + */ + protected function buildInputsArray(Report $report): array + { + return $report->filters->map(fn($filter) => [ + 'key' => $filter->key, + 'type' => $filter->type, + 'label' => $filter->label, + 'nullable' => $filter->nullable, + 'default' => $filter->default_value, + 'options' => $filter->options, + ])->toArray(); + } + + /** + * Build columns array from report columns. + */ + protected function buildColumnsArray(Report $report): array + { + return $report->columns + ->where('visible', true) + ->map(fn($col) => [ + 'key' => $col->key, + 'label' => $col->label, + ]) + ->toArray(); + } + + /** + * Ensure derived export/display fields exist on row objects. + */ + protected function normalizeRow(object $row): object + { + if (isset($row->contract) && ! isset($row->contract_reference)) { + $row->contract_reference = $row->contract->reference ?? null; + } + if (isset($row->assignedUser) && ! isset($row->assigned_user_name)) { + $row->assigned_user_name = $row->assignedUser->name ?? null; + } + + return $row; + } + + /** + * Convert rows for XLSX export: dates to Excel serial numbers, numbers to numeric, contract refs to text. + * + * @param iterable $rows + * @param array $keys + * @return array> + */ + protected function prepareXlsxArray(iterable $rows, array $keys): array + { + $out = []; + foreach ($rows as $r) { + $line = []; + foreach ($keys as $k) { + $v = data_get($r, $k); + if ($k === 'contract_reference') { + $line[] = (string) $v; + + continue; + } + if (str_ends_with($k, '_at')) { + if (empty($v)) { + $line[] = null; + } else { + try { + $dt = \Carbon\Carbon::parse($v); + $line[] = \PhpOffice\PhpSpreadsheet\Shared\Date::dateTimeToExcel($dt); + } catch (\Throwable $e) { + $line[] = (string) $v; + } + } + + continue; + } + if (is_int($v) || is_float($v)) { + $line[] = $v; + } elseif (is_numeric($v) && is_string($v)) { + // cast numeric-like strings unless they are identifiers that we want as text + $line[] = (strpos($k, 'id') !== false) ? (int) $v : ($v + 0); + } else { + $line[] = $v; + } + } + $out[] = $line; + } + + return $out; + } + + /** + * Convert 1-based index to Excel column letter. + */ + protected function excelColumnLetter(int $index): string + { + $letter = ''; + while ($index > 0) { + $mod = ($index - 1) % 26; + $letter = chr(65 + $mod).$letter; + $index = intdiv($index - $mod, 26) - 1; + } + + return $letter; + } +} diff --git a/app/Http/Controllers/Settings/ReportSettingsController.php b/app/Http/Controllers/Settings/ReportSettingsController.php new file mode 100644 index 0000000..6e09a70 --- /dev/null +++ b/app/Http/Controllers/Settings/ReportSettingsController.php @@ -0,0 +1,293 @@ +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.'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index edf1166..78e2dac 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -57,6 +57,7 @@ public function share(Request $request): array 'error' => fn () => $request->session()->get('error'), 'warning' => fn () => $request->session()->get('warning'), 'info' => fn () => $request->session()->get('info'), + 'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling ], 'notifications' => function () use ($request) { try { diff --git a/app/Http/Resources/ActivityCollection.php b/app/Http/Resources/ActivityCollection.php new file mode 100644 index 0000000..c63f40e --- /dev/null +++ b/app/Http/Resources/ActivityCollection.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + // Transform data to add user_name attribute + $this->collection->transform(function ($activity) { + $activity->setAttribute('user_name', optional($activity->user)->name); + + return $activity; + }); + + return $this->resource->toArray(); + } +} diff --git a/app/Http/Resources/ContractCollection.php b/app/Http/Resources/ContractCollection.php new file mode 100644 index 0000000..d5d7cd2 --- /dev/null +++ b/app/Http/Resources/ContractCollection.php @@ -0,0 +1,19 @@ + + */ + public function toArray(Request $request): array + { + return $this->resource->toArray(); + } +} diff --git a/app/Http/Resources/DocumentCollection.php b/app/Http/Resources/DocumentCollection.php new file mode 100644 index 0000000..22e0dad --- /dev/null +++ b/app/Http/Resources/DocumentCollection.php @@ -0,0 +1,21 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'data' => $this->collection, + ]; + } +} diff --git a/app/Jobs/ProcessLargeImportJob.php b/app/Jobs/ProcessLargeImportJob.php new file mode 100644 index 0000000..7b90954 --- /dev/null +++ b/app/Jobs/ProcessLargeImportJob.php @@ -0,0 +1,107 @@ + $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(), + ]); + } +} diff --git a/app/Models/Activity.php b/app/Models/Activity.php index d8c8950..3a6afa1 100644 --- a/app/Models/Activity.php +++ b/app/Models/Activity.php @@ -2,6 +2,8 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Attributes\Scope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -57,6 +59,69 @@ protected static function booted() }); } + /** + * Scope activities to those linked to contracts within a specific segment. + */ + #[Scope] + public function scopeForSegment(Builder $query, int $segmentId, array $contractIds): Builder + { + return $query->where(function ($q) use ($contractIds) { + $q->whereNull('contract_id'); + if (! empty($contractIds)) { + $q->orWhereIn('contract_id', $contractIds); + } + }); + } + + /** + * Scope activities with decoded base64 filters. + */ + #[Scope] + public function scopeWithFilters(Builder $query, ?string $encodedFilters, \App\Models\ClientCase $clientCase): Builder + { + if (empty($encodedFilters)) { + return $query; + } + + try { + $decompressed = base64_decode($encodedFilters); + $filters = json_decode($decompressed, true); + + if (! is_array($filters)) { + return $query; + } + + if (! empty($filters['action_id'])) { + $query->where('action_id', $filters['action_id']); + } + + if (! empty($filters['contract_uuid'])) { + $contract = $clientCase->contracts()->where('uuid', $filters['contract_uuid'])->first(['id']); + if ($contract) { + $query->where('contract_id', $contract->id); + } + } + + if (! empty($filters['user_id'])) { + $query->where('user_id', $filters['user_id']); + } + + if (! empty($filters['date_from'])) { + $query->whereDate('created_at', '>=', $filters['date_from']); + } + + if (! empty($filters['date_to'])) { + $query->whereDate('created_at', '<=', $filters['date_to']); + } + } catch (\Throwable $e) { + \Log::error('Invalid activity filter format', [ + 'error' => $e->getMessage(), + ]); + } + + return $query; + } + public function action(): BelongsTo { return $this->belongsTo(\App\Models\Action::class); diff --git a/app/Models/Contract.php b/app/Models/Contract.php index be4d12b..ade64e3 100644 --- a/app/Models/Contract.php +++ b/app/Models/Contract.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Traits\Uuid; +use Illuminate\Database\Eloquent\Attributes\Scope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -27,6 +29,7 @@ class Contract extends Model 'end_date', 'client_case_id', 'type_id', + 'active', 'description', 'meta', ]; @@ -78,6 +81,20 @@ protected function endDate(): Attribute ); } + /** + * Scope contracts to those in a specific segment with active pivot. + */ + #[Scope] + public function scopeForSegment(Builder $query, int $segmentId): Builder + { + return $query->whereExists(function ($q) use ($segmentId) { + $q->from('contract_segment') + ->whereColumn('contract_segment.contract_id', 'contracts.id') + ->where('contract_segment.segment_id', $segmentId) + ->where('contract_segment.active', true); + }); + } + public function type(): BelongsTo { return $this->belongsTo(\App\Models\ContractType::class, 'type_id'); @@ -124,6 +141,10 @@ 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) diff --git a/app/Models/ImportEntity.php b/app/Models/ImportEntity.php index d8b26c7..034d0d5 100644 --- a/app/Models/ImportEntity.php +++ b/app/Models/ImportEntity.php @@ -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', ]; } diff --git a/app/Models/ImportTemplate.php b/app/Models/ImportTemplate.php index b63ac8f..ae83ff3 100644 --- a/app/Models/ImportTemplate.php +++ b/app/Models/ImportTemplate.php @@ -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); diff --git a/app/Models/Report.php b/app/Models/Report.php new file mode 100644 index 0000000..05feaae --- /dev/null +++ b/app/Models/Report.php @@ -0,0 +1,48 @@ + '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'); + } +} diff --git a/app/Models/ReportColumn.php b/app/Models/ReportColumn.php new file mode 100644 index 0000000..6c63ddd --- /dev/null +++ b/app/Models/ReportColumn.php @@ -0,0 +1,33 @@ + 'boolean', + 'visible' => 'boolean', + 'order' => 'integer', + 'format_options' => 'array', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class); + } +} diff --git a/app/Models/ReportCondition.php b/app/Models/ReportCondition.php new file mode 100644 index 0000000..6195ae4 --- /dev/null +++ b/app/Models/ReportCondition.php @@ -0,0 +1,33 @@ + 'boolean', + 'order' => 'integer', + 'group_id' => 'integer', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class); + } +} diff --git a/app/Models/ReportEntity.php b/app/Models/ReportEntity.php new file mode 100644 index 0000000..149ff58 --- /dev/null +++ b/app/Models/ReportEntity.php @@ -0,0 +1,29 @@ + 'integer', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class); + } +} diff --git a/app/Models/ReportFilter.php b/app/Models/ReportFilter.php new file mode 100644 index 0000000..a3afd3f --- /dev/null +++ b/app/Models/ReportFilter.php @@ -0,0 +1,32 @@ + 'boolean', + 'order' => 'integer', + 'options' => 'array', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class); + } +} diff --git a/app/Models/ReportOrder.php b/app/Models/ReportOrder.php new file mode 100644 index 0000000..6e57217 --- /dev/null +++ b/app/Models/ReportOrder.php @@ -0,0 +1,25 @@ + 'integer', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class); + } +} diff --git a/app/Services/ClientCaseDataService.php b/app/Services/ClientCaseDataService.php new file mode 100644 index 0000000..1933c6b --- /dev/null +++ b/app/Services/ClientCaseDataService.php @@ -0,0 +1,181 @@ +contracts() + ->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at']) + ->with([ + 'type:id,name', + 'account' => function ($q) { + $q->select([ + 'accounts.id', + 'accounts.contract_id', + 'accounts.type_id', + 'accounts.initial_amount', + 'accounts.balance_amount', + 'accounts.promise_date', + 'accounts.created_at', + 'accounts.updated_at', + ])->orderByDesc('accounts.id'); + }, + 'segments:id,name', + 'objects:id,contract_id,reference,name,description,type,created_at', + ]) + ->orderByDesc('created_at'); + + if (! empty($segmentId)) { + $query->forSegment($segmentId); + } + + $perPage = max(1, min(100, $perPage)); + + return $query->paginate($perPage, ['*'], 'contracts_page')->withQueryString(); + } + + /** + * Get paginated activities for a client case with optional segment and filter constraints. + */ + public function getActivities( + ClientCase $clientCase, + ?int $segmentId = null, + ?string $encodedFilters = null, + array $contractIds = [], + int $perPage = 20 + ): LengthAwarePaginator { + $query = $clientCase->activities() + ->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name']) + ->orderByDesc('created_at'); + + if (! empty($segmentId)) { + $query->forSegment($segmentId, $contractIds); + } + + if (! empty($encodedFilters)) { + $query->withFilters($encodedFilters, $clientCase); + } + + $perPage = max(1, min(100, $perPage)); + + return $query->paginate($perPage, ['*'], 'activities_page')->withQueryString(); + } + + /** + * Get merged documents from case and its contracts. + */ + public function getDocuments(ClientCase $clientCase, array $contractIds = [], int $perPage = 15): LengthAwarePaginator + { + $query = null; + $caseDocsQuery = Document::query() + ->select([ + 'documents.id', + 'documents.uuid', + 'documents.documentable_id', + 'documents.documentable_type', + 'documents.name', + 'documents.file_name', + 'documents.original_name', + 'documents.extension', + 'documents.mime_type', + 'documents.size', + 'documents.created_at', + 'documents.is_public', + \DB::raw('NULL as contract_reference'), + \DB::raw('NULL as contract_uuid'), + \DB::raw("'{$clientCase->uuid}' as client_case_uuid"), + \DB::raw('users.name as created_by'), + ]) + ->join('users', 'documents.user_id', '=', 'users.id') + ->where('documents.documentable_type', ClientCase::class) + ->where('documents.documentable_id', $clientCase->id); + + if (! empty($contractIds)) { + // Get contract references for mapping + $contracts = Contract::query() + ->whereIn('id', $contractIds) + ->get(['id', 'uuid', 'reference']) + ->keyBy('id'); + + $contractDocsQuery = Document::query() + ->select([ + 'documents.id', + 'documents.uuid', + 'documents.documentable_id', + 'documents.documentable_type', + 'documents.name', + 'documents.file_name', + 'documents.original_name', + 'documents.extension', + 'documents.mime_type', + 'documents.size', + 'documents.created_at', + 'documents.is_public', + 'contracts.reference as contract_reference', + 'contracts.uuid as contract_uuid', + \DB::raw('NULL as client_case_uuid'), + \DB::raw('users.name as created_by'), + ]) + ->join('users', 'documents.user_id', '=', 'users.id') + ->join('contracts', 'documents.documentable_id', '=', 'contracts.id') + ->where('documents.documentable_type', Contract::class) + ->whereIn('documents.documentable_id', $contractIds); + + // Union the queries + $query = $caseDocsQuery->union($contractDocsQuery); + } else { + $query = $caseDocsQuery; + } + + return \DB::table(\DB::raw("({$query->toSql()}) as documents")) + ->mergeBindings($query->getQuery()) + ->orderByDesc('created_at') + ->paginate($perPage, ['*'], 'documentsPage') + ->withQueryString(); + } + + /** + * Get archive metadata from latest non-reactivate archive setting. + */ + public function getArchiveMeta(): array + { + $latestArchiveSetting = \App\Models\ArchiveSetting::query() + ->where('enabled', true) + ->where(function ($q) { + $q->whereNull('reactivate')->orWhere('reactivate', false); + }) + ->orderByDesc('id') + ->first(); + + $archiveSegmentId = optional($latestArchiveSetting)->segment_id; + $relatedArchiveTables = []; + + if ($latestArchiveSetting) { + $entities = (array) $latestArchiveSetting->entities; + foreach ($entities as $edef) { + if (isset($edef['related']) && is_array($edef['related'])) { + foreach ($edef['related'] as $rel) { + $relatedArchiveTables[] = $rel; + } + } + } + $relatedArchiveTables = array_values(array_unique($relatedArchiveTables)); + } + + return [ + 'archive_segment_id' => $archiveSegmentId, + 'related_tables' => $relatedArchiveTables, + ]; + } +} diff --git a/app/Services/DateNormalizer.php b/app/Services/DateNormalizer.php index 7ea90a0..839ba6f 100644 --- a/app/Services/DateNormalizer.php +++ b/app/Services/DateNormalizer.php @@ -2,57 +2,11 @@ namespace App\Services; -class DateNormalizer +/** + * Backward compatibility alias for DateNormalizer. + * Old code references App\Services\DateNormalizer, but actual class is at App\Services\Import\DateNormalizer. + */ +class DateNormalizer extends \App\Services\Import\DateNormalizer { - /** - * Normalize a raw date string to Y-m-d (ISO) or return null if unparseable. - * Accepted examples: 30.10.2025, 30/10/2025, 30-10-2025, 1/2/25, 2025-10-30 - */ - public static function toDate(?string $raw): ?string - { - if ($raw === null) { - return null; - } - $raw = trim($raw); - if ($raw === '') { - return null; - } - - // Common European and ISO formats first (day-first, then ISO) - $candidates = [ - 'd.m.Y', 'd.m.y', - 'd/m/Y', 'd/m/y', - 'd-m-Y', 'd-m-y', - 'Y-m-d', 'Y/m/d', 'Y.m.d', - ]; - - foreach ($candidates as $fmt) { - $dt = \DateTime::createFromFormat($fmt, $raw); - if ($dt instanceof \DateTime) { - $errors = \DateTime::getLastErrors(); - if ((int) ($errors['warning_count'] ?? 0) === 0 && (int) ($errors['error_count'] ?? 0) === 0) { - // Adjust two-digit years to reasonable century (00-69 => 2000-2069, 70-99 => 1970-1999) - $year = (int) $dt->format('Y'); - if ($year < 100) { - $year += ($year <= 69) ? 2000 : 1900; - // Rebuild date with corrected year - $month = (int) $dt->format('m'); - $day = (int) $dt->format('d'); - - return sprintf('%04d-%02d-%02d', $year, $month, $day); - } - - return $dt->format('Y-m-d'); - } - } - } - - // Fallback: strtotime (permissive). If fails, return null. - $ts = @strtotime($raw); - if ($ts === false) { - return null; - } - - return date('Y-m-d', $ts); - } + // This class extends the actual DateNormalizer for backward compatibility } diff --git a/app/Services/Documents/DocumentStreamService.php b/app/Services/Documents/DocumentStreamService.php new file mode 100644 index 0000000..ab53465 --- /dev/null +++ b/app/Services/Documents/DocumentStreamService.php @@ -0,0 +1,221 @@ +disk ?: 'public'; + $relPath = $this->normalizePath($document->path ?? ''); + + // Handle DOC/DOCX previews for inline viewing + if ($inline) { + $previewResponse = $this->tryPreview($document); + if ($previewResponse) { + return $previewResponse; + } + } + + // Try to find the file using multiple path candidates + $found = $this->findFile($disk, $relPath); + + if (! $found) { + // Try public/ fallback + $found = $this->tryPublicFallback($relPath); + if (! $found) { + abort(404, 'Document file not found'); + } + } + + $headers = $this->buildHeaders($document, $inline); + + // Try streaming first + $stream = Storage::disk($disk)->readStream($found); + if ($stream !== false) { + return response()->stream(function () use ($stream) { + fpassthru($stream); + }, 200, $headers); + } + + // Fallbacks on readStream failure + return $this->fallbackStream($disk, $found, $document, $relPath, $headers); + } + + /** + * Normalize path for Windows and legacy prefixes. + */ + protected function normalizePath(string $path): string + { + $path = str_replace('\\', '/', $path); + $path = ltrim($path, '/'); + if (str_starts_with($path, 'public/')) { + $path = substr($path, 7); + } + + return $path; + } + + /** + * Build path candidates to try. + */ + protected function buildPathCandidates(string $relPath, ?string $documentPath): array + { + $candidates = [$relPath]; + $raw = $documentPath ? ltrim(str_replace('\\', '/', $documentPath), '/') : null; + + if ($raw && $raw !== $relPath) { + $candidates[] = $raw; + } + if (str_starts_with($relPath, 'storage/')) { + $candidates[] = substr($relPath, 8); + } + if ($raw && str_starts_with($raw, 'storage/')) { + $candidates[] = substr($raw, 8); + } + + return array_unique($candidates); + } + + /** + * Try to find file using path candidates. + */ + protected function findFile(string $disk, string $relPath, ?string $documentPath = null): ?string + { + $candidates = $this->buildPathCandidates($relPath, $documentPath); + + foreach ($candidates as $cand) { + if (Storage::disk($disk)->exists($cand)) { + return $cand; + } + } + + return null; + } + + /** + * Try public/ fallback path. + */ + protected function tryPublicFallback(string $relPath): ?string + { + $publicFull = public_path($relPath); + $real = @realpath($publicFull); + $publicRoot = @realpath(public_path()); + $realN = $real ? str_replace('\\\\', '/', $real) : null; + $rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null; + + if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { + return $real; + } + + return null; + } + + /** + * Try to stream preview for DOC/DOCX files. + */ + protected function tryPreview(Document $document): StreamedResponse|Response|null + { + $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); + if (! in_array($ext, ['doc', 'docx'])) { + return null; + } + + $previewDisk = config('files.preview_disk', 'public'); + if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) { + $stream = Storage::disk($previewDisk)->readStream($document->preview_path); + if ($stream !== false) { + $previewNameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME); + + return response()->stream(function () use ($stream) { + fpassthru($stream); + }, 200, [ + 'Content-Type' => $document->preview_mime ?: 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.addslashes($previewNameBase.'.pdf').'"', + 'Cache-Control' => 'private, max-age=0, no-cache', + 'Pragma' => 'no-cache', + ]); + } + } + + // Queue preview generation if not available + \App\Jobs\GenerateDocumentPreview::dispatch($document->id); + + return response('Preview is being generated. Please try again shortly.', 202); + } + + /** + * Build response headers. + */ + protected function buildHeaders(Document $document, bool $inline): array + { + $nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME); + $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); + $name = $ext ? ($nameBase.'.'.$ext) : $nameBase; + + return [ + 'Content-Type' => $document->mime_type ?: 'application/octet-stream', + 'Content-Disposition' => ($inline ? 'inline' : 'attachment').'; filename="'.addslashes($name).'"', + 'Cache-Control' => 'private, max-age=0, no-cache', + 'Pragma' => 'no-cache', + ]; + } + + /** + * Fallback streaming methods when readStream fails. + */ + protected function fallbackStream(string $disk, string $found, Document $document, string $relPath, array $headers): StreamedResponse|Response + { + // Fallback 1: get() the bytes directly + try { + $bytes = Storage::disk($disk)->get($found); + if (! is_null($bytes) && $bytes !== false) { + return response($bytes, 200, $headers); + } + } catch (\Throwable $e) { + // Continue to next fallback + } + + // Fallback 2: open via absolute storage path + $abs = null; + try { + if (method_exists(Storage::disk($disk), 'path')) { + $abs = Storage::disk($disk)->path($found); + } + } catch (\Throwable $e) { + $abs = null; + } + + if ($abs && is_file($abs)) { + $fp = @fopen($abs, 'rb'); + if ($fp !== false) { + return response()->stream(function () use ($fp) { + fpassthru($fp); + }, 200, $headers); + } + } + + // Fallback 3: serve from public path if available + $publicFull = public_path($found); + $real = @realpath($publicFull); + if ($real && is_file($real)) { + $fp = @fopen($real, 'rb'); + if ($fp !== false) { + return response()->stream(function () use ($fp) { + fpassthru($fp); + }, 200, $headers); + } + } + + abort(404, 'Document file could not be streamed'); + } +} + diff --git a/app/Services/Import/BaseEntityHandler.php b/app/Services/Import/BaseEntityHandler.php new file mode 100644 index 0000000..ff5757d --- /dev/null +++ b/app/Services/Import/BaseEntityHandler.php @@ -0,0 +1,86 @@ +entityConfig = $entityConfig; + } + + /** + * Validate mapped data using configuration rules. + */ + public function validate(array $mapped): array + { + $rules = $this->entityConfig?->validation_rules ?? []; + + if (empty($rules)) { + return ['valid' => true, 'errors' => []]; + } + + $validator = Validator::make($mapped, $rules); + + if ($validator->fails()) { + return [ + 'valid' => false, + 'errors' => $validator->errors()->all(), + ]; + } + + return ['valid' => true, 'errors' => []]; + } + + /** + * Get processing options from config. + */ + protected function getOption(string $key, mixed $default = null): mixed + { + return $this->entityConfig?->processing_options[$key] ?? $default; + } + + /** + * Determine if a field has changed. + */ + protected function hasChanged($model, string $field, mixed $newValue): bool + { + $current = $model->{$field}; + + if (is_null($newValue) && is_null($current)) { + return false; + } + + return $current != $newValue; + } + + /** + * Track which fields were applied/changed. + */ + protected function trackAppliedFields($model, array $payload): array + { + $applied = []; + + foreach ($payload as $field => $value) { + if ($this->hasChanged($model, $field, $value)) { + $applied[] = $field; + } + } + + return $applied; + } + + /** + * Default implementation returns null - override in specific handlers. + */ + public function resolve(array $mapped, array $context = []): mixed + { + return null; + } +} diff --git a/app/Services/Import/Contracts/EntityHandlerInterface.php b/app/Services/Import/Contracts/EntityHandlerInterface.php new file mode 100644 index 0000000..3a6065e --- /dev/null +++ b/app/Services/Import/Contracts/EntityHandlerInterface.php @@ -0,0 +1,43 @@ + bool, 'errors' => array] + */ + public function validate(array $mapped): array; + + /** + * Get the entity class name this handler manages. + * + * @return string + */ + public function getEntityClass(): string; + + /** + * Resolve existing entity by key/reference. + * + * @param array $mapped Mapped data for this entity + * @param array $context Additional context + * @return mixed|null Existing entity instance or null + */ + public function resolve(array $mapped, array $context = []): mixed; +} diff --git a/app/Services/Import/DateNormalizer.php b/app/Services/Import/DateNormalizer.php new file mode 100644 index 0000000..55d0233 --- /dev/null +++ b/app/Services/Import/DateNormalizer.php @@ -0,0 +1,58 @@ + 2000-2069, 70-99 => 1970-1999) + $year = (int) $dt->format('Y'); + if ($year < 100) { + $year += ($year <= 69) ? 2000 : 1900; + // Rebuild date with corrected year + $month = (int) $dt->format('m'); + $day = (int) $dt->format('d'); + + return sprintf('%04d-%02d-%02d', $year, $month, $day); + } + + return $dt->format('Y-m-d'); + } + } + } + + // Fallback: strtotime (permissive). If fails, return null. + $ts = @strtotime($raw); + if ($ts === false) { + return null; + } + + return date('Y-m-d', $ts); + } +} diff --git a/app/Services/Import/DecimalNormalizer.php b/app/Services/Import/DecimalNormalizer.php new file mode 100644 index 0000000..adcdc0f --- /dev/null +++ b/app/Services/Import/DecimalNormalizer.php @@ -0,0 +1,83 @@ + "958.31" + * - "1.234,56" => "1234.56" + * - "1,234.56" => "1234.56" + * - "1234" => "1234" + * + * Based on ImportProcessor::normalizeDecimal() + */ + public static function normalize(?string $raw): ?string + { + if ($raw === null) { + return null; + } + + // Keep digits, comma, dot, and minus to detect separators + $s = preg_replace('/[^0-9,\.-]/', '', $raw) ?? ''; + $s = trim($s); + + if ($s === '') { + return null; + } + + $lastComma = strrpos($s, ','); + $lastDot = strrpos($s, '.'); + + // Determine decimal separator by last occurrence + $decimalSep = null; + if ($lastComma !== false || $lastDot !== false) { + if ($lastComma === false) { + $decimalSep = '.'; + } elseif ($lastDot === false) { + $decimalSep = ','; + } else { + $decimalSep = $lastComma > $lastDot ? ',' : '.'; + } + } + + // Remove all thousand separators and unify decimal to '.' + if ($decimalSep === ',') { + // Remove all dots (thousand separators) + $s = str_replace('.', '', $s); + // Replace last comma with dot + $pos = strrpos($s, ','); + if ($pos !== false) { + $s[$pos] = '.'; + } + // Remove any remaining commas (unlikely) + $s = str_replace(',', '', $s); + } elseif ($decimalSep === '.') { + // Remove all commas (thousand separators) + $s = str_replace(',', '', $s); + // Dot is already decimal separator + } else { + // No decimal separator: remove commas/dots entirely + $s = str_replace([',', '.'], '', $s); + } + + // Handle negative numbers + $s = ltrim($s, '+'); + $neg = false; + if (str_starts_with($s, '-')) { + $neg = true; + $s = ltrim($s, '-'); + } + // Remove any stray minus signs + $s = str_replace('-', '', $s); + if ($neg) { + $s = '-' . $s; + } + + return $s; + } +} diff --git a/app/Services/Import/EntityResolutionService.php b/app/Services/Import/EntityResolutionService.php new file mode 100644 index 0000000..463401b --- /dev/null +++ b/app/Services/Import/EntityResolutionService.php @@ -0,0 +1,399 @@ +getPersonFromContract($contract); + if ($personId) { + Log::info('EntityResolutionService: Found Person from processed Contract', [ + 'person_id' => $personId, + 'contract_id' => $contract->id, + ]); + return $personId; + } + } + + // 2. Check if ClientCase already processed in this row + if ($clientCase = $context['client_case']['entity'] ?? null) { + if ($clientCase->person_id) { + Log::info('EntityResolutionService: Found Person from processed ClientCase', [ + 'person_id' => $clientCase->person_id, + 'client_case_id' => $clientCase->id, + ]); + return $clientCase->person_id; + } + } + + // 3. Check for existing Contract by reference (before it's processed) + if ($contractRef = $mapped['contract']['reference'] ?? null) { + $personId = $this->getPersonFromContractReference($import->client_id, $contractRef); + if ($personId) { + Log::info('EntityResolutionService: Found Person from existing Contract reference', [ + 'person_id' => $personId, + 'contract_reference' => $contractRef, + ]); + return $personId; + } + } + + // 4. Check for existing ClientCase by client_ref (before it's processed) + if ($clientRef = $mapped['client_case']['client_ref'] ?? null) { + $personId = $this->getPersonFromClientRef($import->client_id, $clientRef); + if ($personId) { + Log::info('EntityResolutionService: Found Person from existing ClientCase client_ref', [ + 'person_id' => $personId, + 'client_ref' => $clientRef, + ]); + return $personId; + } + } + + // 5. Check for existing Person by contact values (email/phone/address) + $personId = $this->resolvePersonByContacts($mapped); + if ($personId) { + Log::info('EntityResolutionService: Found Person from contact values', [ + 'person_id' => $personId, + ]); + return $personId; + } + + // No existing Person found + return null; + } + + /** + * Check if ClientCase exists for this client_ref. + * + * @param int|null $clientId + * @param string $clientRef + * @return bool + */ + public function clientCaseExists(?int $clientId, string $clientRef): bool + { + if (!$clientId || !$clientRef) { + return false; + } + + return ClientCase::where('client_id', $clientId) + ->where('client_ref', $clientRef) + ->exists(); + } + + /** + * Check if Contract exists for this reference. + * + * @param int|null $clientId + * @param string $reference + * @return bool + */ + public function contractExists(?int $clientId, string $reference): bool + { + if (!$clientId || !$reference) { + return false; + } + + return Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->where('contracts.reference', $reference) + ->exists(); + } + + /** + * Get existing ClientCase by client_ref. + * + * @param int|null $clientId + * @param string $clientRef + * @return ClientCase|null + */ + public function getExistingClientCase(?int $clientId, string $clientRef): ?ClientCase + { + if (!$clientId || !$clientRef) { + return null; + } + + return ClientCase::where('client_id', $clientId) + ->where('client_ref', $clientRef) + ->first(); + } + + /** + * Get existing Contract by reference for this client. + * + * @param int|null $clientId + * @param string $reference + * @return Contract|null + */ + public function getExistingContract(?int $clientId, string $reference): ?Contract + { + if (!$clientId || !$reference) { + return null; + } + + return Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->where('contracts.reference', $reference) + ->select('contracts.*') + ->first(); + } + + /** + * Get Person ID from a Contract entity. + * + * @param Contract $contract + * @return int|null + */ + protected function getPersonFromContract(Contract $contract): ?int + { + if ($contract->client_case_id) { + return ClientCase::where('id', $contract->client_case_id) + ->value('person_id'); + } + + return null; + } + + /** + * Get Person ID from existing Contract by reference. + * + * @param int|null $clientId + * @param string $reference + * @return int|null + */ + protected function getPersonFromContractReference(?int $clientId, string $reference): ?int + { + if (!$clientId) { + return null; + } + + $clientCaseId = Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->where('contracts.reference', $reference) + ->value('contracts.client_case_id'); + + if ($clientCaseId) { + return ClientCase::where('id', $clientCaseId) + ->value('person_id'); + } + + return null; + } + + /** + * Get Person ID from existing ClientCase by client_ref. + * + * @param int|null $clientId + * @param string $clientRef + * @return int|null + */ + protected function getPersonFromClientRef(?int $clientId, string $clientRef): ?int + { + if (!$clientId) { + return null; + } + + return ClientCase::where('client_id', $clientId) + ->where('client_ref', $clientRef) + ->value('person_id'); + } + + /** + * Resolve Person by contact values (email, phone, address). + * Checks existing contact records and returns associated Person ID. + * + * @param array $mapped + * @return int|null + */ + protected function resolvePersonByContacts(array $mapped): ?int + { + // Check email (support both single and array formats) + $email = $this->extractContactValue($mapped, 'email', 'value', 'emails'); + if ($email) { + $personId = Email::where('value', trim($email))->value('person_id'); + if ($personId) { + return $personId; + } + } + + // Check phone (support both single and array formats) + $phone = $this->extractContactValue($mapped, 'phone', 'nu', 'person_phones'); + if ($phone) { + $personId = PersonPhone::where('nu', trim($phone))->value('person_id'); + if ($personId) { + return $personId; + } + } + + // Check address (support both single and array formats) + $address = $this->extractContactValue($mapped, 'address', 'address', 'person_addresses'); + if ($address) { + $personId = PersonAddress::where('address', trim($address))->value('person_id'); + if ($personId) { + return $personId; + } + } + + return null; + } + + /** + * Extract contact value from mapped data, supporting multiple formats. + * + * @param array $mapped + * @param string $singularKey e.g., 'email', 'phone', 'address' + * @param string $field Field name within the contact data + * @param string $pluralKey e.g., 'emails', 'person_phones', 'person_addresses' + * @return string|null + */ + protected function extractContactValue(array $mapped, string $singularKey, string $field, string $pluralKey): ?string + { + // Try singular key first (e.g., 'email') + if (isset($mapped[$singularKey][$field])) { + return $mapped[$singularKey][$field]; + } + + // Try plural key (e.g., 'emails') + if (isset($mapped[$pluralKey])) { + // If it's an array of contacts + if (is_array($mapped[$pluralKey])) { + // Try first element if it's an indexed array + if (isset($mapped[$pluralKey][0][$field])) { + return $mapped[$pluralKey][0][$field]; + } + // Try direct field access if it's a single hash + if (isset($mapped[$pluralKey][$field])) { + return $mapped[$pluralKey][$field]; + } + } + } + + return null; + } + + /** + * Check if this row should skip Person creation based on existing entities. + * Used by PersonHandler to determine if Person already exists via chain. + * + * @param Import $import + * @param array $mapped + * @param array $context + * @return bool True if Person should be skipped (already exists) + */ + public function shouldSkipPersonCreation(Import $import, array $mapped, array $context): bool + { + // If we can resolve existing Person, we should skip creation + $personId = $this->resolvePersonFromContext($import, $mapped, $context); + + return $personId !== null; + } + + /** + * Get or create ClientCase for Contract creation. + * Reuses existing ClientCase if found by client_ref. + * + * @param Import $import + * @param array $mapped + * @param array $context + * @return int|null ClientCase ID + */ + public function resolveOrCreateClientCaseForContract(Import $import, array $mapped, array $context): ?int + { + $clientId = $import->client_id; + + if (!$clientId) { + return null; + } + + // If ClientCase already processed in this row, use it + if ($clientCaseId = $context['client_case']['entity']?->id ?? null) { + return $clientCaseId; + } + + // Try to find by client_ref + $clientRef = $mapped['client_case']['client_ref'] ?? $mapped['client_ref'] ?? null; + + if ($clientRef) { + $existing = $this->getExistingClientCase($clientId, $clientRef); + + if ($existing) { + Log::info('EntityResolutionService: Reusing existing ClientCase for Contract', [ + 'client_case_id' => $existing->id, + 'client_ref' => $clientRef, + ]); + + return $existing->id; + } + } + + // Need to create new ClientCase + // Get Person from context (should be processed before Contract now) + $personId = $context['person']['entity']?->id ?? null; + + if (!$personId) { + // Person wasn't in import or wasn't found, try to resolve + $personId = $this->resolvePersonFromContext($import, $mapped, $context); + + if (!$personId) { + // Create minimal Person as last resort + $defaultGroupId = (int) (\App\Models\Person\PersonGroup::min('id') ?? 1); + $personId = Person::create([ + 'type_id' => 1, + 'group_id' => $defaultGroupId, + ])->id; + Log::info('EntityResolutionService: Created minimal Person for new ClientCase', [ + 'person_id' => $personId, + 'group_id' => $defaultGroupId, + ]); + } + } + + $clientCase = ClientCase::create([ + 'client_id' => $clientId, + 'person_id' => $personId, + 'client_ref' => $clientRef, + ]); + + Log::info('EntityResolutionService: Created new ClientCase', [ + 'client_case_id' => $clientCase->id, + 'person_id' => $personId, + 'client_ref' => $clientRef, + ]); + + return $clientCase->id; + } +} diff --git a/app/Services/Import/Handlers/AccountHandler.php b/app/Services/Import/Handlers/AccountHandler.php new file mode 100644 index 0000000..9cab68b --- /dev/null +++ b/app/Services/Import/Handlers/AccountHandler.php @@ -0,0 +1,216 @@ +entityConfig?->validation_rules ?? []; + + unset($rules['contract_id'], $rules['reference']); + + if (empty($rules)) { + return ['valid' => true, 'errors' => []]; + } + + $validator = \Illuminate\Support\Facades\Validator::make($mapped, $rules); + + if ($validator->fails()) { + return [ + 'valid' => false, + 'errors' => $validator->errors()->all(), + ]; + } + + return ['valid' => true, 'errors' => []]; + } + + public function resolve(array $mapped, array $context = []): mixed + { + $reference = $mapped['reference'] ?? null; + $contractId = $mapped['contract_id'] ?? $context['contract']['entity']->id ?? null; + + if (! $reference || ! $contractId) { + return null; + } + + return Account::where('contract_id', $contractId) + ->where('reference', $reference) + ->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Ensure contract context + if (! isset($context['contract'])) { + return [ + 'action' => 'skipped', + 'message' => 'Account requires contract context', + ]; + } + + // Fallback: if account.reference is empty, use contract.reference (matching v1 behavior) + if (empty($mapped['reference'])) { + $contractReference = $context['contract']['entity']->reference ?? null; + if ($contractReference) { + $mapped['reference'] = preg_replace('/\s+/', '', trim((string) $contractReference)); + } + } + + $contractId = $context['contract']['entity']->id; + $mapped['contract_id'] = $contractId; + + $existing = $this->resolve($mapped, $context); + + if ($existing) { + // Track old balance for activity creation + $oldBalance = (float) ($existing->balance_amount ?? 0); + + $payload = $this->buildPayload($mapped, $existing); + $appliedFields = $this->trackAppliedFields($existing, $payload); + + if (empty($appliedFields)) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'No changes detected', + ]; + } + + $existing->fill($payload); + $existing->save(); + + // Create activity if balance changed and tracking is enabled + if ($this->getOption('track_balance_changes', true) && array_key_exists('balance_amount', $appliedFields)) { + $this->createBalanceChangeActivity($existing, $oldBalance, $import, $context); + } + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'applied_fields' => $appliedFields, + ]; + } + + // Create new account + $account = new Account; + $payload = $this->buildPayload($mapped, $account); + + // Ensure required defaults for new accounts + if (!isset($payload['type_id'])) { + $payload['type_id'] = $this->getDefaultAccountTypeId(); + } + + $account->fill($payload); + $account->save(); + + return [ + 'action' => 'inserted', + 'entity' => $account, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + $fieldMap = [ + 'contract_id' => 'contract_id', + 'reference' => 'reference', + 'title' => 'title', + 'description' => 'description', + 'balance_amount' => 'balance_amount', + 'currency' => 'currency', + ]; + + foreach ($fieldMap as $source => $target) { + if (array_key_exists($source, $mapped)) { + $value = $mapped[$source]; + + // Normalize decimal fields (convert comma to period) + if (in_array($source, ['balance_amount', 'initial_amount']) && is_string($value)) { + $value = DecimalNormalizer::normalize($value); + } + + $payload[$target] = $value; + } + } + + return $payload; + } + + /** + * Create activity when account balance changes. + */ + protected function createBalanceChangeActivity(Account $account, float $oldBalance, Import $import, array $context): void + { + if (! $this->getOption('create_activity_on_balance_change', true)) { + return; + } + + try { + $newBalance = (float) ($account->balance_amount ?? 0); + + // Skip if balance didn't actually change + if ($newBalance === $oldBalance) { + return; + } + + $currency = \App\Models\PaymentSetting::first()?->default_currency ?? 'EUR'; + $beforeStr = number_format($oldBalance, 2, ',', '.').' '.$currency; + $afterStr = number_format($newBalance, 2, ',', '.').' '.$currency; + $note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)'; + + // Get client_case_id + $clientCaseId = $account->contract?->client_case_id; + + if ($clientCaseId) { + // Use action_id from import meta if available + $metaActionId = (int) ($import->meta['action_id'] ?? 0); + + if ($metaActionId > 0) { + \App\Models\Activity::create([ + 'due_date' => null, + 'amount' => null, + 'note' => $note, + 'action_id' => $metaActionId, + 'decision_id' => $import->meta['decision_id'] ?? null, + 'client_case_id' => $clientCaseId, + 'contract_id' => $account->contract_id, + ]); + } + } + } catch (\Throwable $e) { + \Log::warning('Failed to create balance change activity', [ + 'account_id' => $account->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Get default account type ID. + */ + protected function getDefaultAccountTypeId(): int + { + return (int) (\App\Models\AccountType::min('id') ?? 1); + } +} \ No newline at end of file diff --git a/app/Services/Import/Handlers/ActivityHandler.php b/app/Services/Import/Handlers/ActivityHandler.php new file mode 100644 index 0000000..a2dbbf2 --- /dev/null +++ b/app/Services/Import/Handlers/ActivityHandler.php @@ -0,0 +1,171 @@ + true, 'errors' => []]; + } + // Skip parent validation for arrays - we'll validate in process() + return ['valid' => true, 'errors' => []]; + } + + // Single value - check if empty + if (empty($note) || trim((string)$note) === '') { + return ['valid' => true, 'errors' => []]; + } + + return parent::validate($mapped); + } + + public function resolve(array $mapped, array $context = []): mixed + { + // Activities typically don't have a unique reference for deduplication + // Override this method if you have specific deduplication logic + return null; + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Handle multiple activities if note is an array + $notes = $mapped['note'] ?? null; + + // If single value, convert to array for uniform processing + if (!is_array($notes)) { + $notes = [$notes]; + } + + $results = []; + $insertedCount = 0; + $skippedCount = 0; + + // Get context IDs once + $clientCaseId = $mapped['client_case_id'] ?? $context['contract']['entity']?->client_case_id ?? null; + $contractId = $mapped['contract_id'] ?? $context['contract']['entity']?->id ?? null; + + foreach ($notes as $note) { + // Skip if note is empty + if (empty($note) || trim((string)$note) === '') { + $skippedCount++; + continue; + } + + // Require at least client_case_id or contract_id based on options + $requireCase = $this->getOption('require_client_case', false); + $requireContract = $this->getOption('require_contract', false); + + if ($requireCase && ! $clientCaseId) { + $skippedCount++; + continue; + } + + if ($requireContract && ! $contractId) { + $skippedCount++; + continue; + } + + // Build activity payload for this note + $payload = ['note' => $note]; + $payload['client_case_id'] = $clientCaseId; + $payload['contract_id'] = $contractId; + + // Set action_id and decision_id from template meta if not in mapped data + if (!isset($mapped['action_id'])) { + $payload['action_id'] = $import->template->meta['activity_action_id'] ?? $this->getDefaultActionId(); + } else { + $payload['action_id'] = $mapped['action_id']; + } + + if (!isset($mapped['decision_id']) && isset($import->template->meta['activity_decision_id'])) { + $payload['decision_id'] = $import->template->meta['activity_decision_id']; + } + + // Create activity + $activity = new \App\Models\Activity; + $activity->fill($payload); + $activity->save(); + + $results[] = $activity; + $insertedCount++; + } + + if ($insertedCount === 0 && $skippedCount > 0) { + return [ + 'action' => 'skipped', + 'message' => 'All activities empty or missing requirements', + ]; + } + + return [ + 'action' => 'inserted', + 'entity' => $results[0] ?? null, + 'entities' => $results, + 'applied_fields' => ['note', 'client_case_id', 'contract_id', 'action_id'], + 'count' => $insertedCount, + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + // Map activity fields + if (isset($mapped['due_date'])) { + $payload['due_date'] = DateNormalizer::toDate((string) $mapped['due_date']); + } + + if (isset($mapped['amount'])) { + $payload['amount'] = is_string($mapped['amount']) ? (float) str_replace(',', '.', $mapped['amount']) : (float) $mapped['amount']; + } + + if (isset($mapped['note'])) { + $payload['note'] = $mapped['note']; + } + + if (isset($mapped['action_id'])) { + $payload['action_id'] = (int) $mapped['action_id']; + } + + if (isset($mapped['decision_id'])) { + $payload['decision_id'] = (int) $mapped['decision_id']; + } + + return $payload; + } + + /** + * Get default action ID (use minimum ID from actions table). + */ + private function getDefaultActionId(): int + { + return (int) (\App\Models\Action::min('id') ?? 1); + } +} diff --git a/app/Services/Import/Handlers/AddressHandler.php b/app/Services/Import/Handlers/AddressHandler.php new file mode 100644 index 0000000..bd2ba29 --- /dev/null +++ b/app/Services/Import/Handlers/AddressHandler.php @@ -0,0 +1,144 @@ + true, 'errors' => []]; + } + // Skip parent validation for arrays - we'll validate in process() + return ['valid' => true, 'errors' => []]; + } + + // Single value - check if empty + if (empty($address) || trim((string)$address) === '') { + return ['valid' => true, 'errors' => []]; + } + + return parent::validate($mapped); + } + + public function resolve(array $mapped, array $context = []): mixed + { + $address = $mapped['address'] ?? null; + $personId = $mapped['person_id'] + ?? ($context['person']['entity']->id ?? null) + ?? ($context['person']?->entity?->id ?? null); + + if (! $address || ! $personId) { + return null; + } + + // Find existing address by exact match for this person + return PersonAddress::where('person_id', $personId) + ->where('address', $address) + ->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Handle multiple addresses if address is an array + $addresses = $mapped['address'] ?? null; + + // If single value, convert to array for uniform processing + if (!is_array($addresses)) { + $addresses = [$addresses]; + } + + $results = []; + $insertedCount = 0; + $skippedCount = 0; + + foreach ($addresses as $address) { + // Skip if address is empty or blank + if (empty($address) || trim((string)$address) === '') { + $skippedCount++; + continue; + } + + // Resolve person_id from context + $personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null; + + if (! $personId) { + $skippedCount++; + continue; + } + + $existing = $this->resolveAddress($address, $personId); + + // Check for duplicates if configured + if ($this->getOption('deduplicate', true) && $existing) { + $skippedCount++; + continue; + } + + // Create new address + $payload = $this->buildPayloadForAddress($address); + $payload['person_id'] = $personId; + + $addressEntity = new PersonAddress; + $addressEntity->fill($payload); + $addressEntity->save(); + + $results[] = $addressEntity; + $insertedCount++; + } + + if ($insertedCount === 0 && $skippedCount > 0) { + return [ + 'action' => 'skipped', + 'message' => 'All addresses empty or duplicates', + ]; + } + + return [ + 'action' => 'inserted', + 'entity' => $results[0] ?? null, + 'entities' => $results, + 'applied_fields' => ['address', 'person_id'], + 'count' => $insertedCount, + ]; + } + + protected function resolveAddress(string $address, int $personId): mixed + { + return PersonAddress::where('person_id', $personId) + ->where('address', $address) + ->first(); + } + + protected function buildPayloadForAddress(string $address): array + { + return [ + 'address' => $address, + 'type_id' => 1, // Default to permanent address + ]; + } +} diff --git a/app/Services/Import/Handlers/CaseObjectHandler.php b/app/Services/Import/Handlers/CaseObjectHandler.php new file mode 100644 index 0000000..6d23db5 --- /dev/null +++ b/app/Services/Import/Handlers/CaseObjectHandler.php @@ -0,0 +1,96 @@ +first(); + if ($object) { + return $object; + } + } + + // Fall back to name if reference not found + if ($name) { + return CaseObject::where('name', $name)->first(); + } + + return null; + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + $existing = $this->resolve($mapped, $context); + + if ($existing) { + // Update existing object + $payload = $this->buildPayload($mapped, $existing); + $appliedFields = $this->trackAppliedFields($existing, $payload); + + if (empty($appliedFields)) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'No changes detected', + ]; + } + + $existing->fill($payload); + $existing->save(); + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'applied_fields' => $appliedFields, + ]; + } + + // Create new case object + $payload = $this->buildPayload($mapped, new CaseObject); + + $caseObject = new CaseObject; + $caseObject->fill($payload); + $caseObject->save(); + + return [ + 'action' => 'inserted', + 'entity' => $caseObject, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + $fields = ['reference', 'name', 'description', 'type', 'contract_id']; + + foreach ($fields as $field) { + if (array_key_exists($field, $mapped)) { + $payload[$field] = $mapped[$field]; + } + } + + return $payload; + } +} diff --git a/app/Services/Import/Handlers/ClientCaseHandler.php b/app/Services/Import/Handlers/ClientCaseHandler.php new file mode 100644 index 0000000..e3edc1b --- /dev/null +++ b/app/Services/Import/Handlers/ClientCaseHandler.php @@ -0,0 +1,163 @@ +resolutionService = new EntityResolutionService(); + } + + public function getEntityClass(): string + { + return ClientCase::class; + } + + public function resolve(array $mapped, array $context = []): mixed + { + $clientRef = $mapped['client_ref'] ?? null; + $clientId = $context['import']?->client_id ?? null; + + if (! $clientRef || ! $clientId) { + return null; + } + + // Find existing case by client_ref for this client + return ClientCase::where('client_id', $clientId) + ->where('client_ref', $clientRef) + ->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + $clientId = $import->client_id ?? null; + + if (! $clientId) { + return [ + 'action' => 'skipped', + 'message' => 'ClientCase requires client_id', + ]; + } + + // PHASE 5: Use Person from context (already processed due to reversed priorities) + // Priority order: explicit person_id > context person > resolved person + $personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null; + + // If no Person in context, try to resolve using EntityResolutionService + if (!$personId) { + $personId = $this->resolutionService->resolvePersonFromContext($import, $mapped, $context); + + if ($personId) { + Log::info('ClientCaseHandler: Resolved Person via EntityResolutionService', [ + 'person_id' => $personId, + ]); + } else { + Log::warning('ClientCaseHandler: No Person found in context or via resolution', [ + 'has_person_context' => isset($context['person']), + 'has_mapped_person_id' => isset($mapped['person_id']), + ]); + } + } else { + Log::info('ClientCaseHandler: Using Person from context/mapping', [ + 'person_id' => $personId, + 'source' => $mapped['person_id'] ? 'mapped' : 'context', + ]); + } + + $existing = $this->resolve($mapped, $context); + + if ($existing) { + // Update if configured + $mode = $this->getOption('update_mode', 'update'); + + if ($mode === 'skip') { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'ClientCase already exists (skip mode)', + ]; + } + + $payload = $this->buildPayload($mapped, $existing); + + // Update person_id if provided and different + if ($personId && $existing->person_id !== $personId) { + $payload['person_id'] = $personId; + } + + $appliedFields = $this->trackAppliedFields($existing, $payload); + + if (empty($appliedFields)) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'No changes detected', + ]; + } + + $existing->fill($payload); + $existing->save(); + + Log::info('ClientCaseHandler: Updated existing ClientCase', [ + 'client_case_id' => $existing->id, + 'person_id' => $existing->person_id, + 'applied_fields' => $appliedFields, + ]); + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'applied_fields' => $appliedFields, + ]; + } + + // Create new client case + $payload = $this->buildPayload($mapped, new ClientCase); + $payload['client_id'] = $clientId; + + if ($personId) { + $payload['person_id'] = $personId; + } + + $clientCase = new ClientCase; + $clientCase->fill($payload); + $clientCase->save(); + + Log::info('ClientCaseHandler: Created new ClientCase', [ + 'client_case_id' => $clientCase->id, + 'person_id' => $clientCase->person_id, + 'client_ref' => $clientCase->client_ref, + ]); + + return [ + 'action' => 'inserted', + 'entity' => $clientCase, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + $fields = ['client_ref']; + + foreach ($fields as $field) { + if (array_key_exists($field, $mapped)) { + $payload[$field] = $mapped[$field]; + } + } + + return $payload; + } +} diff --git a/app/Services/Import/Handlers/ContractHandler.php b/app/Services/Import/Handlers/ContractHandler.php new file mode 100644 index 0000000..e8e82d8 --- /dev/null +++ b/app/Services/Import/Handlers/ContractHandler.php @@ -0,0 +1,316 @@ +resolutionService = new EntityResolutionService(); + } + + public function getEntityClass(): string + { + return Contract::class; + } + + public function resolve(array $mapped, array $context = []): mixed + { + $reference = $mapped['reference'] ?? null; + + if (! $reference) { + return null; + } + + $query = Contract::query(); + + // Scope by client if available + if ($clientId = $context['import']->client_id) { + $query->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->select('contracts.*'); + } + + return $query->where('contracts.reference', $reference)->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Check for existing contract (using resolve method which handles client scoping) + $existing = $this->resolve($mapped, $context); + + if ($existing) { + + + // Check for reactivation FIRST (before update_mode check) + $reactivate = $this->shouldReactivate($context); + + Log::info('ContractHandler: Found existing Contract', [ + 'contract_id' => $existing->id, + 'reference' => $mapped['reference'] ?? null, + 'context' => $context['import'] + ]); + + if ($reactivate && $this->needsReactivation($existing)) { + $reactivated = $this->attemptReactivation($existing, $context); + Log::info('ContractHandler: Reactivate', ['reactivated' => $reactivated]); + if ($reactivated) { + return [ + 'action' => 'reactivated', + 'entity' => $existing, + 'message' => 'Contract reactivated', + ]; + } + } + + // Check update mode + $mode = $this->getOption('update_mode', 'update'); + if ($mode === 'skip') { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'Contract already exists (skip mode)', + ]; + } + + // Update existing contract + $payload = $this->buildPayload($mapped, $existing); + $payload = $this->mergeJsonFields($payload, $existing); + $appliedFields = $this->trackAppliedFields($existing, $payload); + + if (empty($appliedFields)) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'No changes detected', + ]; + } + + $existing->fill($payload); + $existing->save(); + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'applied_fields' => $appliedFields, + ]; + } + + // Create new contract + $contract = new Contract; + $payload = $this->buildPayload($mapped, $contract); + + // Get client_case_id from context or mapped data + $clientCaseId = $mapped['client_case_id'] + ?? $context['client_case']?->entity?->id + ?? null; + + // If no client_case_id, try to create/find one automatically (using EntityResolutionService) + if (!$clientCaseId) { + // Add mapped data to context for EntityResolutionService + $context['mapped'] = $mapped; + $clientCaseId = $this->findOrCreateClientCaseId($context); + } + + if (!$clientCaseId) { + return [ + 'action' => 'invalid', + 'message' => 'Contract requires client_case_id (import must have client_id)', + ]; + } + + $payload['client_case_id'] = $clientCaseId; + + // Ensure required defaults + if (!isset($payload['type_id'])) { + $payload['type_id'] = $this->getDefaultContractTypeId(); + } + if (!isset($payload['start_date'])) { + $payload['start_date'] = now()->toDateString(); + } + + $contract->fill($payload); + $contract->save(); + + return [ + 'action' => 'inserted', + 'entity' => $contract, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + // Map fields according to contract schema + $fieldMap = [ + 'reference' => 'reference', + 'description' => 'description', + 'amount' => 'amount', + 'currency' => 'currency', + 'start_date' => 'start_date', + 'end_date' => 'end_date', + 'active' => 'active', + 'type_id' => 'type_id', + 'client_case_id' => 'client_case_id', + ]; + + foreach ($fieldMap as $source => $target) { + if (array_key_exists($source, $mapped)) { + $payload[$target] = $mapped[$source]; + } + } + + // Handle meta field - merge grouped meta into flat structure + if (!empty($mapped['meta']) && is_array($mapped['meta'])) { + $metaData = []; + foreach ($mapped['meta'] as $grp => $entries) { + if (!is_array($entries)) { + continue; + } + foreach ($entries as $k => $v) { + $metaData[$k] = $v; + } + } + if (!empty($metaData)) { + $payload['meta'] = $metaData; + } + } + + return $payload; + } + + private function getDefaultContractTypeId(): int + { + return (int) (\App\Models\ContractType::min('id') ?? 1); + } + + /** + * Check if reactivation should be attempted. + */ + protected function shouldReactivate(array $context): bool + { + // Row-level reactivate column takes precedence + if (isset($context['raw']['reactivate'])) { + return filter_var($context['raw']['reactivate'], FILTER_VALIDATE_BOOLEAN); + } + + // Then import-level + if (isset($context['import']->reactivate)) { + return (bool) $context['import']->reactivate; + } + + // Finally template-level + if (isset($context['import']->template?->reactivate)) { + return (bool) $context['import']->template->reactivate; + } + + return false; + } + + /** + * Check if entity needs reactivation. + */ + protected function needsReactivation($entity): bool + { + return $entity->active == 0 || $entity->deleted_at !== null; + } + + /** + * Attempt to reactivate soft-deleted or inactive contract. + */ + protected function attemptReactivation(Contract $contract, array $context): bool + { + if (! $this->getOption('supports_reactivation', false)) { + return false; + } + + try { + if ($contract->trashed()) { + $contract->restore(); + } + + $contract->active = 1; + + $contract->save(); + + return true; + } catch (\Throwable $e) { + \Log::error('Contract reactivation failed', [ + 'contract_id' => $contract->id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Merge JSON fields instead of overwriting. + */ + protected function mergeJsonFields(array $payload, $existing): array + { + $mergeFields = $this->getOption('merge_json_fields', []); + + foreach ($mergeFields as $field) { + if (isset($payload[$field]) && isset($existing->{$field})) { + $existingData = is_array($existing->{$field}) ? $existing->{$field} : []; + $newData = is_array($payload[$field]) ? $payload[$field] : []; + $payload[$field] = array_merge($existingData, $newData); + } + } + + return $payload; + } + + /** + * Find or create a ClientCase for this contract (using EntityResolutionService). + */ + protected function findOrCreateClientCaseId(array $context): ?int + { + $import = $context['import'] ?? null; + $mapped = $context['mapped'] ?? []; + $clientId = $import?->client_id ?? null; + + if (!$clientId) { + return null; + } + + // PHASE 4: Use EntityResolutionService to resolve or create ClientCase + // This will reuse existing ClientCase when possible + $clientCaseId = $this->resolutionService->resolveOrCreateClientCaseForContract( + $import, + $mapped, + $context + ); + + if ($clientCaseId) { + Log::info('ContractHandler: Resolved/Created ClientCase for Contract', [ + 'client_case_id' => $clientCaseId, + ]); + } + + return $clientCaseId; + } + + /** + * Generate a unique client_ref. + */ + protected function generateClientRef(int $clientId): string + { + $timestamp = now()->format('ymdHis'); + $random = substr(md5(uniqid()), 0, 4); + return "C{$clientId}-{$timestamp}-{$random}"; + } +} \ No newline at end of file diff --git a/app/Services/Import/Handlers/EmailHandler.php b/app/Services/Import/Handlers/EmailHandler.php new file mode 100644 index 0000000..4cc4bf5 --- /dev/null +++ b/app/Services/Import/Handlers/EmailHandler.php @@ -0,0 +1,123 @@ + true, 'errors' => []]; + } + + // Validate email format - if invalid, mark as valid to skip instead of failing + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + // Return valid=true but we'll skip it in process() + return ['valid' => true, 'errors' => []]; + } + + return parent::validate($mapped); + } + + public function resolve(array $mapped, array $context = []): mixed + { + $value = $mapped['value'] ?? null; + + if (! $value) { + return null; + } + + return Email::where('value', strtolower(trim($value)))->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Skip if email is empty or blank + $email = $mapped['value'] ?? null; + if (empty($email) || trim((string)$email) === '') { + return [ + 'action' => 'skipped', + 'message' => 'Email is empty', + ]; + } + + // Skip if email format is invalid + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return [ + 'action' => 'skipped', + 'message' => 'Invalid email format', + ]; + } + + // Resolve person_id from context + $personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null; + + if (! $personId) { + return [ + 'action' => 'skipped', + 'message' => 'Email requires person_id', + ]; + } + + // Check if this email already exists for THIS person + $existing = Email::where('person_id', $personId) + ->where('value', strtolower(trim($email))) + ->first(); + + // If email already exists for this person, skip + if ($existing) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'Email already exists for this person', + ]; + } + + // Create new email for this person + $payload = $this->buildPayload($mapped, new Email); + $payload['person_id'] = $personId; + + $email = new Email; + $email->fill($payload); + $email->save(); + + return [ + 'action' => 'inserted', + 'entity' => $email, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + if (isset($mapped['value'])) { + $payload['value'] = strtolower(trim($mapped['value'])); + } + + if (isset($mapped['is_primary'])) { + $payload['is_primary'] = (bool) $mapped['is_primary']; + } + + if (isset($mapped['label'])) { + $payload['label'] = $mapped['label']; + } + + return $payload; + } +} diff --git a/app/Services/Import/Handlers/PaymentHandler.php b/app/Services/Import/Handlers/PaymentHandler.php new file mode 100644 index 0000000..4f280b5 --- /dev/null +++ b/app/Services/Import/Handlers/PaymentHandler.php @@ -0,0 +1,224 @@ + true, 'errors' => []]; + } + + return parent::validate($mapped); + } + + public function resolve(array $mapped, array $context = []): mixed + { + $accountId = $mapped['account_id'] ?? $context['account']?->entity?->id ?? null; + $reference = $mapped['reference'] ?? null; + + if (! $accountId || ! $reference) { + return null; + } + + return Payment::where('account_id', $accountId) + ->where('reference', $reference) + ->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Skip if amount is empty or invalid + $amount = $mapped['amount'] ?? null; + if (empty($amount) || !is_numeric($amount)) { + return [ + 'action' => 'skipped', + 'message' => 'Payment amount is empty or invalid', + ]; + } + + // Resolve account - either from mapped data or context + $accountId = $mapped['account_id'] ?? $context['account']?->entity?->id ?? null; + + if (! $accountId) { + return [ + 'action' => 'skipped', + 'message' => 'Payment requires an account', + ]; + } + + // Check for duplicates if configured + if ($this->getOption('deduplicate_by', [])) { + $existing = $this->resolve($mapped, ['account' => (object) ['entity' => (object) ['id' => $accountId]]]); + if ($existing) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'Payment already exists (duplicate by reference)', + ]; + } + } + + // Build payment payload + $payload = $this->buildPayload($mapped, new Payment); + $payload['account_id'] = $accountId; + $payload['created_by'] = $context['user']?->getAuthIdentifier(); + + // Get account balance before payment + $account = Account::find($accountId); + $balanceBefore = $account ? (float) ($account->balance_amount ?? 0) : 0; + + // Create payment + $payment = new Payment; + $payment->fill($payload); + $payment->balance_before = $balanceBefore; + + try { + $payment->save(); + } catch (\Throwable $e) { + // Handle unique constraint violations gracefully + if (str_contains($e->getMessage(), 'payments_account_id_reference_unique')) { + return [ + 'action' => 'skipped', + 'message' => 'Payment duplicate detected (database constraint)', + ]; + } + throw $e; + } + + // Create booking if configured + if ($this->getOption('create_booking', true) && isset($payment->amount)) { + try { + Booking::create([ + 'account_id' => $accountId, + 'payment_id' => $payment->id, + 'amount_cents' => (int) round(((float) $payment->amount) * 100), + 'type' => 'credit', + 'description' => $payment->reference ? ('Plačilo '.$payment->reference) : 'Plačilo', + 'booked_at' => $payment->paid_at ?? now(), + ]); + } catch (\Throwable $e) { + Log::warning('Failed to create booking for payment', [ + 'payment_id' => $payment->id, + 'error' => $e->getMessage(), + ]); + } + } + + // Create activity if configured + if ($this->getOption('create_activity', false)) { + $this->createPaymentActivity($payment, $account, $balanceBefore); + } + + return [ + 'action' => 'inserted', + 'entity' => $payment, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + // Map payment fields + if (isset($mapped['reference'])) { + $payload['reference'] = is_string($mapped['reference']) ? trim($mapped['reference']) : $mapped['reference']; + } + + // Handle amount - support both amount and amount_cents + if (array_key_exists('amount', $mapped)) { + $payload['amount'] = is_string($mapped['amount']) ? (float) str_replace(',', '.', $mapped['amount']) : (float) $mapped['amount']; + } elseif (array_key_exists('amount_cents', $mapped)) { + $payload['amount'] = ((int) $mapped['amount_cents']) / 100.0; + } + + // Payment date - support both paid_at and payment_date + $dateValue = $mapped['paid_at'] ?? $mapped['payment_date'] ?? null; + if ($dateValue) { + $payload['paid_at'] = DateNormalizer::toDate((string) $dateValue); + } + + $payload['currency'] = $mapped['currency'] ?? 'EUR'; + + // Handle meta + $meta = []; + if (is_array($mapped['meta'] ?? null)) { + $meta = $mapped['meta']; + } + if (! empty($mapped['payment_nu'])) { + $meta['payment_nu'] = trim((string) $mapped['payment_nu']); + } + if (! empty($meta)) { + $payload['meta'] = $meta; + } + + return $payload; + } + + protected function createPaymentActivity(Payment $payment, ?Account $account, float $balanceBefore): void + { + try { + $settings = PaymentSetting::first(); + if (! $settings || ! ($settings->create_activity_on_payment ?? false)) { + return; + } + + $amountCents = (int) round(((float) $payment->amount) * 100); + $note = $settings->activity_note_template ?? 'Prejeto plačilo'; + $note = str_replace( + ['{amount}', '{currency}'], + [number_format($amountCents / 100, 2, ',', '.'), $payment->currency ?? 'EUR'], + $note + ); + + // Get updated balance + $account?->refresh(); + $balanceAfter = $account ? (float) ($account->balance_amount ?? 0) : 0; + + $beforeStr = number_format($balanceBefore, 2, ',', '.').' '.($payment->currency ?? 'EUR'); + $afterStr = number_format($balanceAfter, 2, ',', '.').' '.($payment->currency ?? 'EUR'); + $note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: plačilo)"; + + // Resolve client_case_id + $account?->loadMissing('contract'); + $clientCaseId = $account?->contract?->client_case_id; + + if ($clientCaseId) { + $activity = \App\Models\Activity::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, + ]); + $payment->update(['activity_id' => $activity->id]); + } + } catch (\Throwable $e) { + Log::warning('Failed to create activity for payment', [ + 'payment_id' => $payment->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Import/Handlers/PersonHandler.php b/app/Services/Import/Handlers/PersonHandler.php new file mode 100644 index 0000000..53dc4a0 --- /dev/null +++ b/app/Services/Import/Handlers/PersonHandler.php @@ -0,0 +1,200 @@ +resolutionService = new EntityResolutionService(); + } + + public function getEntityClass(): string + { + return Person::class; + } + + public function resolve(array $mapped, array $context = []): mixed + { + // PHASE 3: Use EntityResolutionService to check chain-based deduplication + // This prevents creating duplicate Persons when Contract/ClientCase already exists + $import = $context['import'] ?? null; + + if ($import) { + $personId = $this->resolutionService->resolvePersonFromContext($import, $mapped, $context); + + if ($personId) { + $person = Person::find($personId); + + if ($person) { + Log::info('PersonHandler: Resolved existing Person via chain', [ + 'person_id' => $personId, + 'resolution_method' => 'EntityResolutionService', + ]); + + return $person; + } + } + } + + // Fall back to configured deduplication fields (tax_number, SSN) + $dedupeBy = $this->getOption('deduplicate_by', ['tax_number', 'social_security_number']); + + foreach ($dedupeBy as $field) { + if (! empty($mapped[$field])) { + $person = Person::where($field, $mapped[$field])->first(); + if ($person) { + Log::info('PersonHandler: Resolved existing Person by identifier', [ + 'person_id' => $person->id, + 'field' => $field, + ]); + + return $person; + } + } + } + + return null; + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Add import to context for EntityResolutionService + $context['import'] = $import; + + $existing = $this->resolve($mapped, $context); + + if ($existing) { + // Update if configured + $mode = $this->getOption('update_mode', 'update'); + + if ($mode === 'skip') { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'Person already exists (skip mode)', + ]; + } + + $payload = $this->buildPayload($mapped, $existing); + $appliedFields = $this->trackAppliedFields($existing, $payload); + + if (empty($appliedFields)) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'No changes detected', + ]; + } + + $existing->fill($payload); + $existing->save(); + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'applied_fields' => $appliedFields, + ]; + } + + // Create new person + Log::info('PersonHandler: Creating new Person (no existing entity found)', [ + 'has_tax_number' => !empty($mapped['tax_number']), + 'has_ssn' => !empty($mapped['social_security_number']), + 'has_contract' => isset($context['contract']), + 'has_client_case' => isset($context['client_case']), + ]); + + $person = new Person; + $payload = $this->buildPayload($mapped, $person); + + // Ensure required foreign keys have defaults + if (!isset($payload['group_id'])) { + $payload['group_id'] = $this->getDefaultPersonGroupId(); + } + if (!isset($payload['type_id'])) { + $payload['type_id'] = $this->getDefaultPersonTypeId(); + } + + Log::debug('PersonHandler: Payload before fill', [ + 'payload' => $payload, + 'has_group_id' => isset($payload['group_id']), + 'group_id_value' => $payload['group_id'] ?? null, + ]); + + $person->fill($payload); + + Log::debug('PersonHandler: Person attributes after fill', [ + 'attributes' => $person->getAttributes(), + 'has_group_id' => isset($person->group_id), + 'group_id_value' => $person->group_id ?? null, + ]); + + $person->save(); + + Log::info('PersonHandler: Created new Person', [ + 'person_id' => $person->id, + ]); + + return [ + 'action' => 'inserted', + 'entity' => $person, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + $fieldMap = [ + 'first_name' => 'first_name', + 'last_name' => 'last_name', + 'full_name' => 'full_name', + 'gender' => 'gender', + 'birthday' => 'birthday', + 'tax_number' => 'tax_number', + 'social_security_number' => 'social_security_number', + 'description' => 'description', + 'group_id' => 'group_id', + 'type_id' => 'type_id', + ]; + + foreach ($fieldMap as $source => $target) { + if (array_key_exists($source, $mapped)) { + $value = $mapped[$source]; + + // Normalize date fields + if ($source === 'birthday' && $value) { + $value = DateNormalizer::toDate((string) $value); + } + + $payload[$target] = $value; + } + } + + return $payload; + } + + private function getDefaultPersonGroupId(): int + { + return (int) (PersonGroup::min('id') ?? 1); + } + + private function getDefaultPersonTypeId(): int + { + return (int) (PersonType::min('id') ?? 1); + } +} diff --git a/app/Services/Import/Handlers/PhoneHandler.php b/app/Services/Import/Handlers/PhoneHandler.php new file mode 100644 index 0000000..a8c88df --- /dev/null +++ b/app/Services/Import/Handlers/PhoneHandler.php @@ -0,0 +1,153 @@ + true, 'errors' => []]; + } + // Skip parent validation for arrays - we'll validate in process() + return ['valid' => true, 'errors' => []]; + } + + // Single value - check if empty or invalid + if (empty($phone) || trim((string)$phone) === '' || $phone === '0') { + return ['valid' => true, 'errors' => []]; + } + + return parent::validate($mapped); + } + + public function resolve(array $mapped, array $context = []): mixed + { + $nu = $mapped['nu'] ?? null; + $personId = $mapped['person_id'] + ?? ($context['person']['entity']->id ?? null) + ?? ($context['person']?->entity?->id ?? null); + + if (! $nu || ! $personId) { + return null; + } + + // Normalize phone number for comparison + $normalizedNu = $this->normalizePhoneNumber($nu); + + // Find existing phone by normalized number for this person + return PersonPhone::where('person_id', $personId) + ->where('nu', $normalizedNu) + ->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Handle multiple phones if nu is an array + $phones = $mapped['nu'] ?? null; + + // If single value, convert to array for uniform processing + if (!is_array($phones)) { + $phones = [$phones]; + } + + $results = []; + $insertedCount = 0; + $skippedCount = 0; + + foreach ($phones as $phone) { + // Skip if phone number is empty or blank or '0' + if (empty($phone) || trim((string)$phone) === '' || $phone === '0') { + $skippedCount++; + continue; + } + + // Resolve person_id from context + $personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null; + + if (! $personId) { + $skippedCount++; + continue; + } + + // Normalize phone number + $normalizedPhone = $this->normalizePhoneNumber($phone); + + $existing = $this->resolvePhone($normalizedPhone, $personId); + + // Check for duplicates if configured + if ($this->getOption('deduplicate', true) && $existing) { + $skippedCount++; + continue; + } + + // Create new phone + $payload = [ + 'nu' => $normalizedPhone, + 'person_id' => $personId, + 'type_id' => 1, // Default to mobile + ]; + + $phoneEntity = new PersonPhone; + $phoneEntity->fill($payload); + $phoneEntity->save(); + + $results[] = $phoneEntity; + $insertedCount++; + } + + if ($insertedCount === 0 && $skippedCount > 0) { + return [ + 'action' => 'skipped', + 'message' => 'All phones empty, invalid or duplicates', + ]; + } + + return [ + 'action' => 'inserted', + 'entity' => $results[0] ?? null, + 'entities' => $results, + 'applied_fields' => ['nu', 'person_id'], + 'count' => $insertedCount, + ]; + } + + protected function resolvePhone(string $normalizedPhone, int $personId): mixed + { + return PersonPhone::where('person_id', $personId) + ->where('nu', $normalizedPhone) + ->first(); + } + + /** + * Normalize phone number by removing spaces, dashes, and parentheses. + */ + protected function normalizePhoneNumber(string $phone): string + { + return preg_replace('/[\s\-\(\)]/', '', $phone); + } +} diff --git a/app/Services/Import/ImportServiceV2.php b/app/Services/Import/ImportServiceV2.php new file mode 100644 index 0000000..d58aad0 --- /dev/null +++ b/app/Services/Import/ImportServiceV2.php @@ -0,0 +1,1017 @@ +loadTemplateMeta($import); + + // Load entity configurations and handlers + $this->loadEntityConfigurations(); + + // Only CSV/TSV supported for now + if (! in_array($import->source_type, ['csv', 'txt'])) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'processing_skipped', + 'level' => 'warning', + 'message' => 'Only CSV/TXT supported in v2 processor.', + ]); + $import->update(['status' => 'completed', 'finished_at' => now()]); + + return compact('total', 'imported', 'skipped', 'invalid'); + } + + $import->update(['status' => 'processing', 'started_at' => $started]); + + $filePath = $import->path; + if (! Storage::disk($import->disk ?? 'local')->exists($filePath)) { + throw new \RuntimeException("File not found: {$filePath}"); + } + + // Check if this is a retry (import_rows already exist) + $isRetry = ImportRow::where('import_id', $import->id)->exists(); + + $fullPath = Storage::disk($import->disk ?? 'local')->path($filePath); + $fh = fopen($fullPath, 'r'); + + if (! $fh) { + throw new \RuntimeException("Could not open file: {$filePath}"); + } + + $meta = $import->meta ?? []; + $hasHeader = (bool) ($meta['has_header'] ?? true); + $delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ','; + + $mappings = $this->loadMappings($import); + $header = null; + $rowNum = 0; + + // Read header if present + if ($hasHeader) { + $header = fgetcsv($fh, 0, $delimiter); + $rowNum++; + } + + // If retry mode, only process failed/invalid rows + if ($isRetry) { + $failedRows = ImportRow::where('import_id', $import->id) + ->whereIn('status', ['invalid', 'failed']) + ->orderBy('row_number') + ->get(); + + foreach ($failedRows as $importRow) { + $total++; + + try { + $rawAssoc = $importRow->raw_data; + $mapped = $importRow->mapped_data; + + // Process entities in priority order within a transaction + $context = ['import' => $import, 'user' => $user, 'import_row' => $importRow]; + + DB::beginTransaction(); + try { + $results = $this->processRow($import, $mapped, $rawAssoc, $context); + + // If processing succeeded, commit the transaction + if ($results['status'] === 'imported' || $results['status'] === 'skipped') { + DB::commit(); + } else { + DB::rollBack(); + } + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + + // Collect entity details from results + $entityData = $this->collectEntityDetails($results); + $entityDetails = $entityData['details']; + $hasErrors = $entityData['hasErrors']; + $hasWarnings = $entityData['hasWarnings']; + + // Handle different result statuses + if ($results['status'] === 'imported') { + $imported++; + $importRow->update([ + 'status' => 'imported', + 'entity_type' => $results['entity_type'] ?? null, + 'entity_id' => $results['entity_id'] ?? null, + ]); + $this->createRowProcessedEvent($import, $user, $importRow->row_number, $entityDetails, $hasWarnings, $rawAssoc); + } elseif ($results['status'] === 'skipped') { + $skipped++; + $importRow->update(['status' => 'skipped']); + $this->createRowSkippedEvent($import, $user, $importRow->row_number, $entityDetails, $rawAssoc); + } else { + $invalid++; + $importRow->update([ + 'status' => 'invalid', + 'errors' => $results['errors'] ?? ['Processing failed'], + ]); + $this->createRowFailedEvent( + $import, + $user, + $importRow->row_number, + $results['errors'] ?? ['Processing failed'], + $entityDetails, + $rawAssoc + ); + } + } catch (\Throwable $e) { + $invalid++; + $this->handleRowException($import, $user, $importRow->row_number, $e); + } + } + + fclose($fh); + } else { + // Normal mode: process all rows from CSV + while (($row = fgetcsv($fh, 0, $delimiter)) !== false) { + $rowNum++; + $total++; + + try { + $rawAssoc = $this->buildRowAssoc($row, $header); + + // Skip empty rows + if ($this->rowIsEffectivelyEmpty($rawAssoc)) { + $skipped++; + continue; + } + + $mapped = $this->applyMappings($rawAssoc, $mappings); + + $rawSha1 = sha1(json_encode($rawAssoc)); + $importRow = ImportRow::create([ + 'import_id' => $import->id, + 'row_number' => $rowNum, + 'record_type' => $this->determineRecordType($mapped), + 'raw_data' => $rawAssoc, + 'mapped_data' => $mapped, + 'status' => 'valid', + 'raw_sha1' => $rawSha1, + ]); + + // Process entities in priority order within a transaction + $context = ['import' => $import, 'user' => $user, 'import_row' => $importRow]; + + DB::beginTransaction(); + try { + $results = $this->processRow($import, $mapped, $rawAssoc, $context); + + // If processing succeeded, commit the transaction + if ($results['status'] === 'imported' || $results['status'] === 'skipped') { + DB::commit(); + } else { + DB::rollBack(); + } + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + + // Collect entity details from results + $entityData = $this->collectEntityDetails($results); + $entityDetails = $entityData['details']; + $hasErrors = $entityData['hasErrors']; + $hasWarnings = $entityData['hasWarnings']; + + // Handle different result statuses + if ($results['status'] === 'imported') { + $imported++; + $importRow->update([ + 'status' => 'imported', + 'entity_type' => $results['entity_type'] ?? null, + 'entity_id' => $results['entity_id'] ?? null, + ]); + $this->createRowProcessedEvent($import, $user, $rowNum, $entityDetails, $hasWarnings, $rawAssoc); + } elseif ($results['status'] === 'skipped') { + $skipped++; + $importRow->update(['status' => 'skipped']); + $this->createRowSkippedEvent($import, $user, $rowNum, $entityDetails, $rawAssoc); + } else { + $invalid++; + $importRow->update([ + 'status' => 'invalid', + 'errors' => $results['errors'] ?? ['Processing failed'], + ]); + $this->createRowFailedEvent( + $import, + $user, + $rowNum, + $results['errors'] ?? ['Processing failed'], + $entityDetails, + $rawAssoc + ); + } + } catch (\Throwable $e) { + $invalid++; + $this->handleRowException($import, $user, $rowNum, $e); + } + } + + fclose($fh); + } + + $this->finalizeImport($import, $user, $total, $imported, $skipped, $invalid); + } catch (\Throwable $e) { + $this->handleFatalException($import, $user, $e); + throw $e; + } + + return compact('total', 'imported', 'skipped', 'invalid'); + } + + /** + * Load entity configurations from database. + */ + protected function loadEntityConfigurations(): void + { + $entities = ImportEntity::where('is_active', true) + ->orderBy('priority', 'desc') + ->get(); + + foreach ($entities as $entity) { + $this->entityConfigs[$entity->canonical_root] = $entity; + + // Instantiate handler if specified + if ($entity->handler_class && class_exists($entity->handler_class)) { + $this->handlers[$entity->canonical_root] = new $entity->handler_class($entity); + } + } + } + + /** + * Load mappings for import. + */ + protected function loadMappings(Import $import) + { + return DB::table('import_mappings') + ->where('import_id', $import->id) + ->orderBy('position') + ->get(); + } + + /** + * Build associative array from row. + */ + protected function buildRowAssoc(array $row, ?array $header): array + { + if ($header) { + $result = []; + foreach ($header as $idx => $col) { + $result[$col] = $row[$idx] ?? null; + } + + return $result; + } + + return array_combine(range(0, count($row) - 1), $row); + } + + /** + * Check if row is effectively empty. + */ + protected function rowIsEffectivelyEmpty(array $raw): bool + { + foreach ($raw as $val) { + if (! is_null($val) && trim((string) $val) !== '') { + return false; + } + } + + return true; + } + + /** + * Apply mappings to raw data. + */ + protected function applyMappings(array $raw, $mappings): array + { + $mapped = []; + + // Group mappings by target field to handle concatenation + $groupedMappings = []; + foreach ($mappings as $mapping) { + $targetField = $mapping->target_field; + if (!isset($groupedMappings[$targetField])) { + $groupedMappings[$targetField] = []; + } + $groupedMappings[$targetField][] = $mapping; + } + + foreach ($groupedMappings as $targetField => $fieldMappings) { + // Special handling for meta fields: contracts.meta or other_entity.meta + if (str_ends_with($targetField, '.meta')) { + $this->applyMetaMappings($mapped, $targetField, $fieldMappings, $raw); + continue; + } + + // Group by group number from options + $valuesByGroup = []; + + foreach ($fieldMappings as $mapping) { + $sourceCol = $mapping->source_column; + + if (!isset($raw[$sourceCol])) { + continue; + } + + $value = $raw[$sourceCol]; + + // Apply transform + if ($mapping->transform) { + $value = $this->applyTransform($value, $mapping->transform); + } + + // Get group from options + $options = $mapping->options ? json_decode($mapping->options, true) : []; + $group = $options['group'] ?? null; + + // Group values by their group number + if ($group !== null) { + // Same group = concatenate + if (!isset($valuesByGroup[$group])) { + $valuesByGroup[$group] = []; + } + $valuesByGroup[$group][] = $value; + } else { + // No group = each gets its own group + $valuesByGroup[] = [$value]; + } + } + + // Now set the values + foreach ($valuesByGroup as $values) { + if (count($values) === 1) { + // Single value - set directly + $this->setNestedValue($mapped, $targetField, $values[0]); + } else { + // Multiple values in same group - concatenate with newline + $concatenated = implode("\n", array_filter($values, fn($v) => !empty($v) && trim((string)$v) !== '')); + if (!empty($concatenated)) { + $this->setNestedValue($mapped, $targetField, $concatenated); + } + } + } + } + + return $mapped; + } + + /** + * Apply meta mappings with special structure: entity.meta[group][key] = {title, value, type} + */ + protected function applyMetaMappings(array &$mapped, string $targetField, array $fieldMappings, array $raw): void + { + // Extract entity from target field: contracts.meta -> contracts + $entity = str_replace('.meta', '', $targetField); + + foreach ($fieldMappings as $mapping) { + $sourceCol = $mapping->source_column; + + if (!isset($raw[$sourceCol])) { + continue; + } + + $value = $raw[$sourceCol]; + + // Apply transform + if ($mapping->transform) { + $value = $this->applyTransform($value, $mapping->transform); + } + + // Get options + $options = $mapping->options ? json_decode($mapping->options, true) : []; + $metaKey = $options['key'] ?? null; + $metaType = $options['type'] ?? 'string'; + $group = $options['group'] ?? '1'; + + if (!$metaKey) { + continue; + } + + // Coerce value based on type + $coerced = $value; + if ($metaType === 'number') { + if (is_string($coerced)) { + $norm = DecimalNormalizer::normalize($coerced); + $coerced = is_numeric($norm) ? (float) $norm : $coerced; + } + } elseif ($metaType === 'boolean') { + if (is_string($coerced)) { + $lc = strtolower(trim($coerced)); + if (in_array($lc, ['1', 'true', 'yes', 'y'], true)) { + $coerced = true; + } elseif (in_array($lc, ['0', 'false', 'no', 'n'], true)) { + $coerced = false; + } + } else { + $coerced = (bool) $coerced; + } + } elseif ($metaType === 'date') { + $coerced = is_scalar($coerced) ? $this->normalizeDate((string) $coerced) : null; + } else { + // string or unspecified: cast scalars to string + if (is_scalar($coerced)) { + $coerced = (string) $coerced; + } + } + + // Initialize structure if needed + if (!isset($mapped[$entity])) { + $mapped[$entity] = []; + } + if (!isset($mapped[$entity]['meta']) || !is_array($mapped[$entity]['meta'])) { + $mapped[$entity]['meta'] = []; + } + if (!isset($mapped[$entity]['meta'][$group])) { + $mapped[$entity]['meta'][$group] = []; + } + + // Store as structure with title, value and type + $entry = [ + 'title' => $sourceCol, + 'value' => $coerced, + ]; + if ($metaType) { + $entry['type'] = $metaType; + } + + $mapped[$entity]['meta'][$group][$metaKey] = $entry; + } + } + + /** + * Apply transform to value. + */ + protected function applyTransform(mixed $value, string $transform): mixed + { + return match (strtolower($transform)) { + 'trim' => is_string($value) ? trim($value) : $value, + 'upper' => is_string($value) ? strtoupper($value) : $value, + 'lower' => is_string($value) ? strtolower($value) : $value, + 'date' => $this->normalizeDate($value), + default => $value, + }; + } + + /** + * Normalize date value. + */ + protected function normalizeDate(mixed $value): ?string + { + if (empty($value)) { + return null; + } + + try { + return DateNormalizer::toDate((string) $value); + } catch (\Throwable $e) { + return null; + } + } + + /** + * Set nested value in array using dot notation. + * If the key already exists, convert to array and append the new value. + */ + protected function setNestedValue(array &$array, string $key, mixed $value): void + { + $keys = explode('.', $key); + $current = &$array; + + foreach ($keys as $i => $k) { + if ($i === count($keys) - 1) { + // If key already exists, convert to array and append + if (isset($current[$k])) { + // Convert existing single value to array if needed + if (!is_array($current[$k])) { + $current[$k] = [$current[$k]]; + } + // Append new value + $current[$k][] = $value; + } else { + // Set as single value + $current[$k] = $value; + } + } else { + if (! isset($current[$k]) || ! is_array($current[$k])) { + $current[$k] = []; + } + $current = &$current[$k]; + } + } + } + + /** + * Determine record type from mapped data. + */ + protected function determineRecordType(array $mapped): string + { + if (isset($mapped['payment'])) { + return 'payment'; + } + if (isset($mapped['activity'])) { + return 'activity'; + } + if (isset($mapped['contract'])) { + return 'contract'; + } + if (isset($mapped['account'])) { + return 'account'; + } + + return 'contact'; + } + + /** + * Process a single row through all entity handlers. + */ + protected function processRow(Import $import, array $mapped, array $raw, array $context): array + { + $entityResults = []; + $lastEntityType = null; + $lastEntityId = null; + $hasErrors = false; + + // Process entities in configured priority order + foreach ($this->entityConfigs as $root => $config) { + // Check if this entity exists in mapped data (support aliases) + $mappedKey = $this->findMappedKey($mapped, $root, $config); + + if (!$mappedKey || !isset($mapped[$mappedKey])) { + continue; + } + + $handler = $this->handlers[$root] ?? null; + + if (! $handler) { + continue; + } + + try { + // Validate before processing + $validation = $handler->validate($mapped[$mappedKey]); + if (! $validation['valid']) { + $entityResults[$root] = [ + 'action' => 'invalid', + 'errors' => $validation['errors'], + 'level' => 'error', + ]; + $hasErrors = true; + + // Don't stop processing, continue to other entities to collect all errors + continue; + } + + // Pass previous results as context + $result = $handler->process($import, $mapped[$mappedKey], $raw, array_merge($context, $entityResults)); + + $entityResults[$root] = $result; + + // Track last successful entity for row status + if (in_array($result['action'] ?? null, ['inserted', 'updated'])) { + $lastEntityType = $handler->getEntityClass(); + $lastEntityId = $result['entity']?->id ?? null; + } + + // Post-contract actions (segment attachment, activity creation) + if ($root === 'contract' && in_array($result['action'] ?? null, ['inserted', 'updated', 'reactivated'])) { + try { + $this->postContractActions($import, $result['entity'], $context); + } catch (\Throwable $e) { + Log::warning('Post-contract action failed', [ + 'import_id' => $import->id, + 'contract_id' => $result['entity']->id ?? null, + 'error' => $e->getMessage(), + ]); + } + } + } catch (\Throwable $e) { + $hasErrors = true; + + Log::error("Handler failed for entity {$root}", [ + 'import_id' => $import->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $entityResults[$root] = [ + 'action' => 'failed', + 'level' => 'error', + 'errors' => [$e->getMessage()], + 'exception' => [ + 'message' => $e->getMessage(), + 'file' => basename($e->getFile()), + 'line' => $e->getLine(), + 'class' => get_class($e), + ], + ]; + + // Continue to process other entities to collect all errors + continue; + } + } + + // If we had errors, return invalid status + if ($hasErrors) { + $allErrors = []; + foreach ($entityResults as $root => $result) { + if (isset($result['errors'])) { + $allErrors[] = "{$root}: " . implode(', ', $result['errors']); + } + } + + return [ + 'status' => 'invalid', + 'errors' => $allErrors, + 'results' => $entityResults, + ]; + } + + return [ + 'status' => $lastEntityId ? 'imported' : 'skipped', + 'entity_type' => $lastEntityType, + 'entity_id' => $lastEntityId, + 'results' => $entityResults, + ]; + } + + /** + * Find the key in mapped data that corresponds to this canonical root. + */ + protected function findMappedKey(array $mapped, string $canonicalRoot, $config): ?string + { + // First check canonical_root itself + if (isset($mapped[$canonicalRoot])) { + return $canonicalRoot; + } + + // Then check key (e.g., 'contracts', 'person_addresses') + if (isset($mapped[$config->key])) { + return $config->key; + } + + // Then check aliases + $aliases = $config->aliases ?? []; + foreach ($aliases as $alias) { + if (isset($mapped[$alias])) { + return $alias; + } + } + + return null; + } + /** + * Load template meta flags for special processing modes. + */ + protected function loadTemplateMeta(Import $import): void + { + $this->templateMeta = optional($import->template)->meta ?? []; + $this->paymentsImport = (bool) ($this->templateMeta['payments_import'] ?? false); + $this->historyImport = (bool) ($this->templateMeta['history_import'] ?? false); + $this->contractKeyMode = $this->templateMeta['contract_key_mode'] ?? null; + } + + /** + * Collect entity details from processing results. + */ + protected function collectEntityDetails(array $results): array + { + $entityDetails = []; + $hasErrors = false; + $hasWarnings = false; + + if (isset($results['results']) && is_array($results['results'])) { + foreach ($results['results'] as $entityKey => $result) { + $action = $result['action'] ?? 'unknown'; + $message = $result['message'] ?? null; + $count = $result['count'] ?? 1; + + $detail = [ + 'entity' => $entityKey, + 'action' => $action, + 'count' => $count, + ]; + + if ($message) { + $detail['message'] = $message; + } + + if ($action === 'invalid' || isset($result['errors'])) { + $detail['level'] = 'error'; + $detail['errors'] = $result['errors'] ?? []; + $hasErrors = true; + } elseif ($action === 'skipped') { + $detail['level'] = 'warning'; + $hasWarnings = true; + } else { + $detail['level'] = 'info'; + } + + if (isset($result['exception'])) { + $detail['exception'] = $result['exception']; + $hasErrors = true; + } + + $entityDetails[] = $detail; + } + } + + return [ + 'details' => $entityDetails, + 'hasErrors' => $hasErrors, + 'hasWarnings' => $hasWarnings, + ]; + } + + /** + * Create a success event for a processed row. + */ + protected function createRowProcessedEvent( + Import $import, + ?Authenticatable $user, + int $rowNum, + array $entityDetails, + bool $hasWarnings, + array $rawData = [] + ): void { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'row_processed', + 'level' => $hasWarnings ? 'warning' : 'info', + 'message' => "Row {$rowNum} processed successfully", + 'context' => [ + 'row' => $rowNum, + 'entity_details' => $entityDetails, + 'raw_data' => $rawData, + ], + ]); + } + + /** + * Create a skip event for a skipped row. + */ + protected function createRowSkippedEvent( + Import $import, + ?Authenticatable $user, + int $rowNum, + array $entityDetails, + array $rawData = [] + ): void { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'row_skipped', + 'level' => 'warning', + 'message' => "Row {$rowNum} skipped", + 'context' => [ + 'row' => $rowNum, + 'entity_details' => $entityDetails, + 'raw_data' => $rawData, + ], + ]); + } + + /** + * Create a failure event for a failed row. + */ + protected function createRowFailedEvent( + Import $import, + ?Authenticatable $user, + int $rowNum, + array $errors, + array $entityDetails, + array $rawData = [] + ): void { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'row_failed', + 'level' => 'error', + 'message' => "Row {$rowNum} failed: " . implode(', ', $errors), + 'context' => [ + 'row' => $rowNum, + 'errors' => $errors, + 'entity_details' => $entityDetails, + 'raw_data' => $rawData, + ], + ]); + } + + /** + * Handle row processing exception. + */ + protected function handleRowException( + Import $import, + ?Authenticatable $user, + int $rowNum, + \Throwable $e + ): void { + Log::error('ImportServiceV2 row processing failed', [ + 'import_id' => $import->id, + 'row' => $rowNum, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'row_failed', + 'level' => 'error', + 'message' => "Row {$rowNum} exception: {$e->getMessage()}", + 'context' => [ + 'row' => $rowNum, + 'exception' => [ + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], + ], + ]); + } + + /** + * Finalize import with completion event. + */ + protected function finalizeImport( + Import $import, + ?Authenticatable $user, + int $total, + int $imported, + int $skipped, + int $invalid + ): void { + // If there are any invalid rows, mark import as failed, not completed + $status = $invalid > 0 ? 'failed' : 'completed'; + $eventLevel = $invalid > 0 ? 'error' : 'info'; + $eventName = $invalid > 0 ? 'processing_failed' : 'processing_completed'; + + $import->update([ + 'status' => $status, + 'finished_at' => now(), + 'total_rows' => $total, + 'imported_rows' => $imported, + 'valid_rows' => $imported, + 'invalid_rows' => $invalid, + ]); + + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => $eventName, + 'level' => $eventLevel, + 'message' => "Processed {$total} rows: {$imported} imported, {$skipped} skipped, {$invalid} invalid", + ]); + } + + /** + * Handle fatal processing exception. + */ + protected function handleFatalException( + Import $import, + ?Authenticatable $user, + \Throwable $e + ): void { + Log::error('ImportServiceV2 processing failed', [ + 'import_id' => $import->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $import->update(['status' => 'failed', 'finished_at' => now()]); + + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'processing_failed', + 'level' => 'error', + 'message' => $e->getMessage(), + ]); + } + + /** + * Post-contract actions: attach segment, create activity with decision. + * Matches ImportProcessor::postContractActions() behavior. + */ + protected function postContractActions(Import $import, $contract, array $context = []): void + { + $meta = $import->meta ?? []; + $segmentId = (int) ($meta['segment_id'] ?? 0); + $decisionId = (int) ($meta['decision_id'] ?? 0); + $templateName = (string) ($meta['template_name'] ?? optional($import->template)->name ?? ''); + $actionId = (int) ($meta['action_id'] ?? 0); + + // Attach segment to contract as the main (active) segment if provided + if ($segmentId > 0) { + // Ensure the segment exists on the client case and is active + $ccSeg = \DB::table('client_case_segment') + ->where('client_case_id', $contract->client_case_id) + ->where('segment_id', $segmentId) + ->first(); + if (! $ccSeg) { + \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 (! $ccSeg->active) { + \DB::table('client_case_segment') + ->where('id', $ccSeg->id) + ->update(['active' => true, 'updated_at' => now()]); + } + + // Deactivate all other segments for this contract to make this the main one + \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('segment_id', '!=', $segmentId) + ->update(['active' => false, 'updated_at' => now()]); + + // Upsert the selected segment as active for this contract + $pivot = \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('segment_id', $segmentId) + ->first(); + if ($pivot) { + if (! $pivot->active) { + \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(), + ]); + } + } + + // Create activity if decision provided + if ($decisionId > 0) { + \App\Models\Activity::create([ + 'decision_id' => $decisionId, + 'action_id' => $actionId > 0 ? $actionId : null, + 'contract_id' => $contract->id, + 'client_case_id' => $contract->client_case_id, + 'note' => trim('Imported via template'.($templateName ? ': '.$templateName : '')), + ]); + } + } +} diff --git a/app/Services/ImportSimulationService.php b/app/Services/Import/ImportSimulationService.php similarity index 100% rename from app/Services/ImportSimulationService.php rename to app/Services/Import/ImportSimulationService.php diff --git a/app/Services/Import/ImportSimulationServiceV2.php b/app/Services/Import/ImportSimulationServiceV2.php new file mode 100644 index 0000000..7c86c78 --- /dev/null +++ b/app/Services/Import/ImportSimulationServiceV2.php @@ -0,0 +1,832 @@ +loadEntityConfigurations(); + + // Only CSV/TXT supported + if (! in_array($import->source_type, ['csv', 'txt'])) { + return $this->errorPayload('Podprti so samo CSV/TXT formati.'); + } + + $filePath = $import->path; + if (! Storage::disk($import->disk ?? 'local')->exists($filePath)) { + return $this->errorPayload("Datoteka ni najdena: {$filePath}"); + } + + $fullPath = Storage::disk($import->disk ?? 'local')->path($filePath); + $fh = fopen($fullPath, 'r'); + + if (! $fh) { + return $this->errorPayload("Datoteke ni mogoče odpreti: {$filePath}"); + } + + $meta = $import->meta ?? []; + $hasHeader = (bool) ($meta['has_header'] ?? true); + $delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ','; + + $mappings = $this->loadMappings($import); + if (empty($mappings)) { + fclose($fh); + + return $this->errorPayload('Ni shranjenih mapiranj za ta uvoz.'); + } + + $header = null; + $rowNum = 0; + + // Read header if present + if ($hasHeader) { + $header = fgetcsv($fh, 0, $delimiter); + $rowNum++; + } + + $simRows = []; + $summaries = $this->initSummaries(); + $rowCount = 0; + + while (($row = fgetcsv($fh, 0, $delimiter)) !== false && $rowCount < $limit) { + $rowNum++; + $rowCount++; + + try { + $rawAssoc = $this->buildRowAssoc($row, $header); + + // Skip empty rows + if ($this->rowIsEffectivelyEmpty($rawAssoc)) { + continue; + } + + $mapped = $this->applyMappings($rawAssoc, $mappings); + + // Group mapped data by entity (from "entity.field" to nested structure) + $groupedMapped = $this->groupMappedDataByEntity($mapped); + + \Log::info('ImportSimulation: Grouped entities', [ + 'row' => $rowNum, + 'entity_keys' => array_keys($groupedMapped), + 'config_roots' => array_keys($this->entityConfigs), + ]); + + // Simulate processing for this row + // Context must include 'import' for EntityResolutionService to work + $context = [ + 'import' => $import, + 'simulation' => true, + ]; + + $rowResult = $this->simulateRow($import, $groupedMapped, $rawAssoc, $context, $verbose); + + // Update summaries - handle both single and array results + foreach ($rowResult['entities'] ?? [] as $entityKey => $entityDataOrArray) { + // Extract entity root from key (e.g., 'person', 'contract', etc.) + $root = explode('.', $entityKey)[0]; + + // Handle array of results (grouped entities) + if (is_array($entityDataOrArray) && isset($entityDataOrArray[0])) { + foreach ($entityDataOrArray as $entityData) { + $action = $entityData['action'] ?? 'skip'; + if (!isset($summaries[$root])) { + $summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0]; + } + $summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1; + } + } else { + // Single result + $action = $entityDataOrArray['action'] ?? 'skip'; + if (!isset($summaries[$root])) { + $summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0]; + } + $summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1; + } + } + + $simRows[] = [ + 'row_number' => $rowNum, + 'raw_data' => $verbose ? $rawAssoc : null, + 'entities' => $rowResult['entities'], + 'warnings' => $rowResult['warnings'] ?? [], + 'errors' => $rowResult['errors'] ?? [], + ]; + } catch (\Throwable $e) { + $simRows[] = [ + 'row_number' => $rowNum, + 'raw_data' => $verbose ? ($rawAssoc ?? null) : null, + 'entities' => [], + 'warnings' => [], + 'errors' => [$e->getMessage()], + ]; + } + } + + fclose($fh); + + return [ + 'success' => true, + 'total_simulated' => $rowCount, + 'limit' => $limit, + 'summaries' => $summaries, + 'rows' => $simRows, + 'meta' => [ + 'has_header' => $hasHeader, + 'delimiter' => $delimiter, + 'mappings_count' => count($mappings), + ], + ]; + } catch (\Throwable $e) { + return $this->errorPayload('Napaka pri simulaciji: '.$e->getMessage()); + } + } + + /** + * Simulate processing a single row without database writes. + * + * Updated to match ImportServiceV2 logic: + * - Process entities in priority order from entity configs + * - Accumulate entity results in context for chain resolution + * - Pass proper context to handlers for EntityResolutionService + */ + protected function simulateRow(Import $import, array $mapped, array $raw, array $context, bool $verbose): array + { + $entities = []; + $warnings = []; + $errors = []; + $entityResults = []; + + // Process entities in configured priority order (like ImportServiceV2) + foreach ($this->entityConfigs as $root => $config) { + // Check if this entity exists in mapped data + $mappedKey = $this->findMappedKey($mapped, $root, $config); + + if (!$mappedKey || !isset($mapped[$mappedKey])) { + continue; + } + + $handler = $this->handlers[$root] ?? null; + + if (!$handler) { + continue; + } + + try { + // Check if this is an array of entities (grouped) + $entityDataArray = is_array($mapped[$mappedKey]) && isset($mapped[$mappedKey][0]) + ? $mapped[$mappedKey] + : [$mapped[$mappedKey]]; + + $results = []; + + foreach ($entityDataArray as $entityData) { + // Validate + $validation = $handler->validate($entityData); + if (!$validation['valid']) { + $results[] = [ + 'action' => 'invalid', + 'data' => $entityData, + 'errors' => $validation['errors'], + ]; + continue; + } + + // Skip empty/invalid data that handlers would skip during real import + // Phone: skip if nu is 0, empty, or #N/A + if ($root === 'phone') { + $nu = $entityData['nu'] ?? null; + if (empty($nu) || $nu === '0' || $nu === '#N/A' || trim((string)$nu) === '') { + continue; // Skip this phone entirely + } + } + + // Address: skip if address is empty or #N/A + if ($root === 'address') { + $address = $entityData['address'] ?? null; + if (empty($address) || $address === '#N/A' || trim((string)$address) === '') { + continue; // Skip this address entirely + } + } + + // Email: skip if value is 0, empty, or #N/A + if ($root === 'email') { + $email = $entityData['value'] ?? null; + if (empty($email) || $email === '0' || $email === '#N/A' || trim((string)$email) === '') { + continue; // Skip this email entirely + } + } + + // DEBUG: Log context for grouped entities + if (in_array($root, ['phone', 'address'])) { + Log::info("ImportSimulation: Resolving grouped entity", [ + 'entity' => $root, + 'data' => $entityData, + 'has_person_in_context' => isset($entityResults['person']), + 'person_id' => $entityResults['person']['entity']->id ?? null, + 'context_keys' => array_keys(array_merge($context, $entityResults)), + ]); + } + + // Resolve existing entity (uses EntityResolutionService internally) + // Pass accumulated entityResults as context for chain resolution + try { + $existingEntity = $handler->resolve($entityData, array_merge($context, $entityResults)); + } catch (\Throwable $resolutionError) { + // In simulation mode, resolution may fail due to simulated entities + // Just treat as new entity + \Log::debug("ImportSimulation: Resolution failed (treating as new)", [ + 'entity' => $root, + 'error' => $resolutionError->getMessage(), + ]); + $existingEntity = null; + } + + if ($existingEntity) { + // Would update existing + $results[] = [ + 'action' => 'update', + 'reference' => $this->getEntityReference($existingEntity, $root), + 'existing_id' => $existingEntity->id ?? null, + 'data' => $entityData, + 'existing_data' => $verbose ? $this->extractExistingData($existingEntity) : null, + 'changes' => $verbose ? $this->detectChanges($existingEntity, $entityData) : null, + ]; + + // Add to entityResults for subsequent handlers + $entityResults[$root] = [ + 'entity' => $existingEntity, + 'action' => 'updated', + ]; + } else { + // Would create new + $results[] = [ + 'action' => 'create', + 'data' => $entityData, + ]; + + // Simulate entity creation for context (no actual ID) + // Mark as simulated so resolution service knows not to use model methods + $simulatedEntity = (object) $entityData; + $simulatedEntity->_simulated = true; + + $entityResults[$root] = [ + 'entity' => $simulatedEntity, + 'action' => 'inserted', + '_simulated' => true, + ]; + } + } + + // Store results (single or array) + $entities[$mappedKey] = (count($results) === 1) ? $results[0] : $results; + } catch (\Throwable $e) { + $entities[$mappedKey] = [ + 'action' => 'error', + 'errors' => [$e->getMessage()], + ]; + $errors[] = "{$root}: {$e->getMessage()}"; + } + } + + return compact('entities', 'warnings', 'errors'); + } + + /** + * Find the mapped key for an entity (supports aliases and common variations). + */ + protected function findMappedKey(array $mapped, string $canonicalRoot, $config): ?string + { + // Check canonical root exactly + if (isset($mapped[$canonicalRoot])) { + return $canonicalRoot; + } + + // Build comprehensive list of variations + $variations = [$canonicalRoot]; + + // Generate plural variations (handle -y endings correctly) + if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) { + // activity -> activities + $variations[] = substr($canonicalRoot, 0, -1) . 'ies'; + } else { + // address -> addresses + $variations[] = $canonicalRoot . 's'; + } + + // Add singular form (remove trailing s or ies) + if (str_ends_with($canonicalRoot, 'ies')) { + $variations[] = substr($canonicalRoot, 0, -3) . 'y'; // activities -> activity + } else { + $variations[] = rtrim($canonicalRoot, 's'); // addresses -> address + } + + // Add person_ prefixed versions + $variations[] = 'person_' . $canonicalRoot; + + // person_activity -> person_activities + if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) { + $variations[] = 'person_' . substr($canonicalRoot, 0, -1) . 'ies'; + } else { + $variations[] = 'person_' . $canonicalRoot . 's'; + } + + // Special handling: if canonical is 'address', also check 'person_addresses' + if ($canonicalRoot === 'address') { + $variations[] = 'person_addresses'; + } + // Special handling: if canonical is 'phone', also check 'person_phones' + if ($canonicalRoot === 'phone') { + $variations[] = 'person_phones'; + } + // Reverse: if canonical has 'person_', also check without it + if (str_starts_with($canonicalRoot, 'person_')) { + $withoutPerson = str_replace('person_', '', $canonicalRoot); + $variations[] = $withoutPerson; + // Handle plural variations + if (str_ends_with($withoutPerson, 'y') && !str_ends_with($withoutPerson, 'ay') && !str_ends_with($withoutPerson, 'ey')) { + $variations[] = substr($withoutPerson, 0, -1) . 'ies'; + } else { + $variations[] = rtrim($withoutPerson, 's'); + $variations[] = $withoutPerson . 's'; + } + } + + $variations = array_unique($variations); + + foreach ($variations as $variation) { + if (isset($mapped[$variation])) { + \Log::debug("ImportSimulation: Matched entity", [ + 'canonical_root' => $canonicalRoot, + 'matched_key' => $variation, + ]); + return $variation; + } + } + + // Check aliases if configured + if (isset($config->options['aliases'])) { + $aliases = is_array($config->options['aliases']) ? $config->options['aliases'] : []; + foreach ($aliases as $alias) { + if (isset($mapped[$alias])) { + return $alias; + } + } + } + + \Log::debug("ImportSimulation: No match found for entity", [ + 'canonical_root' => $canonicalRoot, + 'tried_variations' => array_slice($variations, 0, 5), + 'available_keys' => array_keys($mapped), + ]); + + return null; + } + + /** + * Group mapped data by entity from "entity.field" format to nested structure. + * Handles both single values and arrays (for grouped entities like multiple addresses). + * + * Special handling: + * - activity.note arrays are kept together (single activity with multiple notes) + * - Other array values create separate entity instances (e.g., multiple addresses) + * + * Input: ['person.first_name' => 'John', 'person.last_name' => 'Doe', 'email.value' => ['a@b.com', 'c@d.com']] + * Output: ['person' => ['first_name' => 'John', 'last_name' => 'Doe'], 'email' => [['value' => 'a@b.com'], ['value' => 'c@d.com']]] + */ + protected function groupMappedDataByEntity(array $mapped): array + { + $grouped = []; + + foreach ($mapped as $key => $value) { + if (!str_contains($key, '.')) { + continue; + } + + [$entity, $field] = explode('.', $key, 2); + + // Handle array values + if (is_array($value)) { + // Special case: activity.note should be kept as array in single instance + if ($entity === 'activity' || $entity === 'activities') { + if (!isset($grouped[$entity])) { + $grouped[$entity] = []; + } + $grouped[$entity][$field] = $value; // Keep as array + } else { + // For other entities, only create multiple instances if: + // 1. Entity doesn't exist yet, OR + // 2. Entity has no other fields yet (is empty array) + if (!isset($grouped[$entity])) { + $grouped[$entity] = []; + } + + // If entity already has string-keyed fields, just set the array as field value + // Otherwise, create separate instances + $hasStringKeys = !empty($grouped[$entity]) && isset(array_keys($grouped[$entity])[0]) && is_string(array_keys($grouped[$entity])[0]); + + if ($hasStringKeys) { + // Entity has fields already - don't split, keep array as-is + $grouped[$entity][$field] = $value; + } else { + // Create separate entity instances for each array value + foreach ($value as $idx => $val) { + if (!isset($grouped[$entity][$idx])) { + $grouped[$entity][$idx] = []; + } + $grouped[$entity][$idx][$field] = $val; + } + } + } + } else { + // Single value + if (!isset($grouped[$entity])) { + $grouped[$entity] = []; + } + + // Check if entity is already an array of instances (from previous grouped field) + if (!empty($grouped[$entity]) && is_int(array_key_first($grouped[$entity]))) { + // Entity has multiple instances - add field to all instances + foreach ($grouped[$entity] as &$instance) { + if (is_array($instance)) { + $instance[$field] = $value; + } + } + unset($instance); + } else { + // Simple associative array - add field + $grouped[$entity][$field] = $value; + } + } + } + + return $grouped; + } + + /** + * Determine if entity data is grouped (array of instances). + */ + protected function isGroupedEntity($data): bool + { + if (!is_array($data)) { + return false; + } + + // Check if numeric array (multiple instances) + $keys = array_keys($data); + return isset($keys[0]) && is_int($keys[0]); + } + + /** + * Extract existing entity data as array. + */ + protected function extractExistingData($entity): array + { + if (method_exists($entity, 'toArray')) { + return $entity->toArray(); + } + + return (array) $entity; + } + + /** + * Detect changes between existing entity and new data. + */ + protected function detectChanges($existingEntity, array $newData): array + { + $changes = []; + + foreach ($newData as $key => $newValue) { + $oldValue = $existingEntity->{$key} ?? null; + + // Convert to comparable formats + if ($oldValue instanceof \Carbon\Carbon) { + $oldValue = $oldValue->format('Y-m-d'); + } + + if ($oldValue != $newValue && ! ($oldValue === null && $newValue === '')) { + $changes[$key] = [ + 'old' => $oldValue, + 'new' => $newValue, + ]; + } + } + + return $changes; + } + + /** + * Get a reference string for an entity. + */ + protected function getEntityReference($entity, string $root): string + { + if (isset($entity->reference)) { + return (string) $entity->reference; + } + if (isset($entity->value)) { + return (string) $entity->value; + } + if (isset($entity->title)) { + return (string) $entity->title; + } + if (isset($entity->id)) { + return "{$root}#{$entity->id}"; + } + + return 'N/A'; + } + + /** + * Load entity configurations from database. + */ + protected function loadEntityConfigurations(): void + { + $entities = ImportEntity::where('is_active', true) + ->orderBy('priority', 'desc') + ->get(); + + foreach ($entities as $entity) { + $this->entityConfigs[$entity->canonical_root] = $entity; + + // Instantiate handler if configured + if ($entity->handler_class && class_exists($entity->handler_class)) { + $this->handlers[$entity->canonical_root] = app($entity->handler_class, ['entity' => $entity]); + } + } + } + + /** + * Get handler for entity root. + */ + protected function getHandler(string $root) + { + return $this->handlers[$root] ?? null; + } + + /** + * Load mappings from import_mappings table. + * Uses target_field in "entity.field" format. + * Supports multiple sources mapping to same target (for groups). + */ + protected function loadMappings(Import $import): array + { + $rows = \DB::table('import_mappings') + ->where('import_id', $import->id) + ->orderBy('position') + ->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']); + + $mappings = []; + foreach ($rows as $row) { + $source = trim((string) $row->source_column); + $target = trim((string) $row->target_field); + + if ($source === '' || $target === '') { + continue; + } + + // Use unique key combining source and target to avoid overwriting + $key = $source . '→' . $target; + + // target_field is in "entity.field" format + $mappings[$key] = [ + 'source' => $source, + 'target' => $target, + 'transform' => $row->transform ?? null, + 'apply_mode' => $row->apply_mode ?? 'both', + 'options' => $row->options ? json_decode($row->options, true) : [], + ]; + } + + return $mappings; + } + + /** + * Build associative array from row data. + */ + protected function buildRowAssoc(array $row, ?array $header): array + { + if ($header) { + return array_combine($header, array_pad($row, count($header), null)); + } + + // Use numeric indices if no header + return array_combine( + array_map(fn ($i) => "col_{$i}", array_keys($row)), + $row + ); + } + + /** + * Check if row is effectively empty. + */ + protected function rowIsEffectivelyEmpty(array $assoc): bool + { + foreach ($assoc as $value) { + if ($value !== null && $value !== '') { + return false; + } + } + + return true; + } + + /** + * Apply mappings to raw row data. + * Returns array keyed by "entity.field". + * + * Updated to match ImportServiceV2: + * - Supports group option for concatenating multiple sources + * - Returns flat array with "entity.field" keys (no nesting) + */ + protected function applyMappings(array $raw, array $mappings): array + { + $mapped = []; + + // Group mappings by target field to handle concatenation (same as ImportServiceV2) + $groupedMappings = []; + foreach ($mappings as $mapping) { + $target = $mapping['target']; + if (!isset($groupedMappings[$target])) { + $groupedMappings[$target] = []; + } + $groupedMappings[$target][] = $mapping; + } + + foreach ($groupedMappings as $targetField => $fieldMappings) { + // Group by group number from options + $valuesByGroup = []; + + foreach ($fieldMappings as $mapping) { + $source = $mapping['source']; + + if (!isset($raw[$source])) { + continue; + } + + $value = $raw[$source]; + + // Apply transform if specified + if (!empty($mapping['transform'])) { + $value = $this->applyTransform($value, $mapping['transform']); + } + + // Get group from options + $options = $mapping['options'] ?? []; + $group = $options['group'] ?? null; + + // Group values by their group number (same logic as ImportServiceV2) + if ($group !== null) { + // Same group = concatenate + if (!isset($valuesByGroup[$group])) { + $valuesByGroup[$group] = []; + } + $valuesByGroup[$group][] = $value; + } else { + // No group = each gets its own group + $valuesByGroup[] = [$value]; + } + } + + // Now set the values - KEEP FLAT, DON'T NEST + foreach ($valuesByGroup as $values) { + if (count($values) === 1) { + // Single value - add to array if key exists, otherwise set directly + if (isset($mapped[$targetField])) { + // Convert to array and append + if (!is_array($mapped[$targetField])) { + $mapped[$targetField] = [$mapped[$targetField]]; + } + $mapped[$targetField][] = $values[0]; + } else { + $mapped[$targetField] = $values[0]; + } + } else { + // Multiple values in same group - concatenate with newline + $concatenated = implode("\n", array_filter($values, fn($v) => !empty($v) && trim((string)$v) !== '')); + if (!empty($concatenated)) { + if (isset($mapped[$targetField])) { + // Convert to array and append + if (!is_array($mapped[$targetField])) { + $mapped[$targetField] = [$mapped[$targetField]]; + } + $mapped[$targetField][] = $concatenated; + } else { + $mapped[$targetField] = $concatenated; + } + } + } + } + } + + return $mapped; + } + + /** + * Set nested value in array using dot notation. + * If the key already exists, convert to array and append the new value. + * + * Same logic as ImportServiceV2. + */ + protected function setNestedValue(array &$array, string $key, mixed $value): void + { + $keys = explode('.', $key); + $current = &$array; + + foreach ($keys as $i => $k) { + if ($i === count($keys) - 1) { + // If key already exists, convert to array and append + if (isset($current[$k])) { + // Convert existing single value to array if needed + if (!is_array($current[$k])) { + $current[$k] = [$current[$k]]; + } + // Append new value + $current[$k][] = $value; + } else { + // Set as single value + $current[$k] = $value; + } + } else { + if (!isset($current[$k]) || !is_array($current[$k])) { + $current[$k] = []; + } + $current = &$current[$k]; + } + } + } + + /** + * Apply transform to a value. + */ + protected function applyTransform(mixed $value, string $transform): mixed + { + return match ($transform) { + 'trim' => trim((string) $value), + 'upper' => strtoupper((string) $value), + 'lower' => strtolower((string) $value), + 'decimal' => (float) str_replace(',', '.', (string) $value), + default => $value, + }; + } + + /** + * Initialize summary counters. + */ + protected function initSummaries(): array + { + $summaries = []; + + foreach (array_keys($this->entityConfigs) as $root) { + $summaries[$root] = [ + 'create' => 0, + 'update' => 0, + 'skip' => 0, + 'invalid' => 0, + ]; + } + + return $summaries; + } + + /** + * Create error payload. + */ + protected function errorPayload(string $message): array + { + return [ + 'success' => false, + 'error' => $message, + 'total_simulated' => 0, + 'summaries' => [], + 'rows' => [], + ]; + } +} diff --git a/app/Services/Import/README.md b/app/Services/Import/README.md new file mode 100644 index 0000000..d2d3487 --- /dev/null +++ b/app/Services/Import/README.md @@ -0,0 +1,347 @@ +# Import System V2 Architecture + +## Overview + +ImportServiceV2 is a refactored, database-driven import processing system that replaces the monolithic ImportProcessor.php with a modular, maintainable architecture. + +## Key Features + +- **Database-driven configuration**: Entity processing rules, validation, and handlers configured in `import_entities` table +- **Pluggable handlers**: Each entity type has its own handler class implementing `EntityHandlerInterface` +- **Queue support**: Large imports can be processed asynchronously via `ProcessLargeImportJob` +- **Validation**: Entity-level validation rules stored in database +- **Priority-based processing**: Entities processed in configured priority order +- **Extensible**: Easy to add new entity types without modifying core service + +## Directory Structure + +``` +app/Services/Import/ +├── Contracts/ +│ └── EntityHandlerInterface.php # Handler contract +├── Handlers/ +│ ├── ContractHandler.php # Contract entity handler +│ ├── AccountHandler.php # Account entity handler +│ ├── PaymentHandler.php # Payment handler (to be implemented) +│ ├── ActivityHandler.php # Activity handler (to be implemented) +│ └── ... # Additional handlers +├── BaseEntityHandler.php # Base handler with common logic +└── ImportServiceV2.php # Main import service +``` + +## Database Schema + +### import_entities Table + +| Column | Type | Description | +|--------|------|-------------| +| id | bigint | Primary key | +| key | string | UI key (plural, e.g., "contracts") | +| canonical_root | string | Canonical root for processor (singular, e.g., "contract") | +| label | string | Human-readable label | +| fields | json | Array of field names | +| field_aliases | json | Field alias mappings | +| aliases | json | Root aliases | +| supports_multiple | boolean | Whether entity supports multiple items per row | +| meta | boolean | Whether entity is metadata | +| rules | json | Suggestion rules | +| ui | json | UI configuration | +| handler_class | string | Fully qualified handler class name | +| validation_rules | json | Laravel validation rules | +| processing_options | json | Handler-specific options | +| is_active | boolean | Whether entity is enabled | +| priority | integer | Processing priority (higher = first) | +| created_at | timestamp | Creation timestamp | +| updated_at | timestamp | Update timestamp | + +## Handler Interface + +All entity handlers must implement `EntityHandlerInterface`: + +```php +interface EntityHandlerInterface +{ + public function process(Import $import, array $mapped, array $raw, array $context = []): array; + public function validate(array $mapped): array; + public function getEntityClass(): string; + public function resolve(array $mapped, array $context = []): mixed; +} +``` + +### Handler Methods + +- **process()**: Main processing method, returns result with action (inserted/updated/skipped) and entity +- **validate()**: Validates mapped data before processing +- **getEntityClass()**: Returns the model class name this handler manages +- **resolve()**: Resolves existing entity by key/reference + +## Creating a New Handler + +1. Create handler class extending `BaseEntityHandler`: + +```php +first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + $existing = $this->resolve($mapped, $context); + + if ($existing) { + // Update logic + $payload = $this->buildPayload($mapped, $existing); + $appliedFields = $this->trackAppliedFields($existing, $payload); + + if (empty($appliedFields)) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'No changes detected', + ]; + } + + $existing->fill($payload); + $existing->save(); + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'applied_fields' => $appliedFields, + ]; + } + + // Create logic + $entity = new YourEntity; + $payload = $this->buildPayload($mapped, $entity); + $entity->fill($payload); + $entity->save(); + + return [ + 'action' => 'inserted', + 'entity' => $entity, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + // Map fields to model attributes + return [ + 'field1' => $mapped['field1'] ?? null, + 'field2' => $mapped['field2'] ?? null, + ]; + } +} +``` + +2. Add configuration to `import_entities` table: + +```php +ImportEntity::create([ + 'key' => 'your_entities', + 'canonical_root' => 'your_entity', + 'label' => 'Your Entities', + 'fields' => ['field1', 'field2'], + 'handler_class' => \App\Services\Import\Handlers\YourEntityHandler::class, + 'validation_rules' => [ + 'field1' => 'required|string', + 'field2' => 'nullable|integer', + ], + 'processing_options' => [ + 'update_mode' => 'update', + ], + 'is_active' => true, + 'priority' => 100, +]); +``` + +## Usage + +### Synchronous Processing + +```php +use App\Services\Import\ImportServiceV2; + +$service = app(ImportServiceV2::class); +$results = $service->process($import, $user); +``` + +### Queue Processing (Large Imports) + +```php +use App\Jobs\ProcessLargeImportJob; + +ProcessLargeImportJob::dispatch($import, $user->id); +``` + +## Processing Options + +Handler-specific options stored in `processing_options` JSON column: + +### Contract Handler +- `update_mode`: 'update' | 'skip' | 'error' +- `create_missing`: boolean + +### Account Handler +- `update_mode`: 'update' | 'skip' +- `require_contract`: boolean + +### Payment Handler (planned) +- `deduplicate_by`: array of fields +- `create_booking`: boolean +- `create_activity`: boolean + +## Migration Path + +### Phase 1: Setup (Current) +- ✅ Create directory structure +- ✅ Add v2 columns to import_entities +- ✅ Create base interfaces and classes +- ✅ Implement ContractHandler and AccountHandler +- ✅ Create ProcessLargeImportJob +- ✅ Create seeder for entity configurations + +### Phase 2: Implementation +- [ ] Implement remaining handlers (Payment, Activity, Person, Contacts) +- [ ] Add comprehensive tests +- [ ] Update controllers to use ImportServiceV2 +- [ ] Add feature flag to toggle between v1 and v2 + +### Phase 3: Migration +- [ ] Run both systems in parallel +- [ ] Compare results and fix discrepancies +- [ ] Migrate all imports to v2 +- [ ] Remove ImportProcessor.php (v1) + +## Testing + +```bash +# Run migrations +php artisan migrate + +# Seed entity configurations +php artisan db:seed --class=ImportEntitiesV2Seeder + +# Run tests +php artisan test --filter=ImportServiceV2 +``` + +## Benefits Over V1 + +1. **Maintainability**: Each entity has its own handler, easier to understand and modify +2. **Testability**: Handlers can be tested independently +3. **Extensibility**: New entities added without touching core service +4. **Configuration**: Business rules in database, no code deployment needed +5. **Queue Support**: Built-in queue support for large imports +6. **Validation**: Entity-level validation separate from processing logic +7. **Priority Control**: Process entities in configurable order +8. **Reusability**: Handlers can be reused across different import scenarios + +## Simulation Service + +ImportSimulationServiceV2 provides a way to preview what an import would do without persisting any data to the database. This is useful for: +- Validating mappings before processing +- Previewing create/update actions +- Detecting errors before running actual import +- Testing handler logic + +### Usage + +```php +use App\Services\Import\ImportSimulationServiceV2; + +$service = app(ImportSimulationServiceV2::class); + +// Simulate first 100 rows (default) +$result = $service->simulate($import); + +// Simulate 50 rows with verbose output +$result = $service->simulate($import, limit: 50, verbose: true); + +// Result structure: +// [ +// 'success' => true, +// 'total_simulated' => 50, +// 'limit' => 50, +// 'summaries' => [ +// 'contract' => ['create' => 10, 'update' => 5, 'skip' => 0, 'invalid' => 1], +// 'account' => ['create' => 20, 'update' => 3, 'skip' => 0, 'invalid' => 0], +// ], +// 'rows' => [ +// [ +// 'row_number' => 2, +// 'entities' => [ +// 'contract' => [ +// 'action' => 'update', +// 'reference' => 'CNT-001', +// 'existing_id' => 123, +// 'data' => ['reference', 'title', 'amount'], +// 'changes' => ['title' => ['old' => 'Old', 'new' => 'New']], +// ], +// ], +// 'warnings' => [], +// 'errors' => [], +// ], +// ], +// 'meta' => [ +// 'has_header' => true, +// 'delimiter' => ',', +// 'mappings_count' => 8, +// ], +// ] +``` + +### CLI Command + +```bash +# Simulate import with ID 123 +php artisan import:simulate-v2 123 + +# Simulate with custom limit +php artisan import:simulate-v2 123 --limit=50 + +# Verbose mode shows field-level changes +php artisan import:simulate-v2 123 --verbose +``` + +### Action Types + +- **create**: Entity doesn't exist, would be created +- **update**: Entity exists, would be updated +- **skip**: Entity exists but update_mode is 'skip' +- **invalid**: Validation failed +- **error**: Processing error occurred + +### Comparison with V1 Simulation + +| Feature | ImportSimulationService (V1) | ImportSimulationServiceV2 | +|---------|------------------------------|---------------------------| +| Handler-based | ❌ Hardcoded logic | ✅ Uses V2 handlers | +| Configuration | ❌ In code | ✅ From database | +| Validation | ❌ Manual | ✅ Handler validation | +| Extensibility | ❌ Modify service | ✅ Add handlers | +| Change detection | ✅ Yes | ✅ Yes | +| Priority ordering | ❌ Fixed | ✅ Configurable | +| Error handling | ✅ Basic | ✅ Comprehensive | + +## Original ImportProcessor.php + +The original file remains at `app/Services/ImportProcessor.php` and can be used as reference for implementing remaining handlers. diff --git a/app/Services/ReferenceDataCache.php b/app/Services/ReferenceDataCache.php new file mode 100644 index 0000000..cf916aa --- /dev/null +++ b/app/Services/ReferenceDataCache.php @@ -0,0 +1,68 @@ + AddressType::all()); + } + + public function getPhoneTypes() + { + return Cache::remember('reference_data:phone_types', self::TTL, fn () => PhoneType::all()); + } + + public function getAccountTypes() + { + return Cache::remember('reference_data:account_types', self::TTL, fn () => AccountType::all()); + } + + public function getContractTypes() + { + return Cache::remember('reference_data:contract_types', self::TTL, fn () => ContractType::whereNull('deleted_at')->get()); + } + + /** + * Clear all reference data cache. + */ + public function clearAll(): void + { + Cache::forget('reference_data:address_types'); + Cache::forget('reference_data:phone_types'); + Cache::forget('reference_data:account_types'); + Cache::forget('reference_data:contract_types'); + } + + /** + * Clear specific reference data cache. + */ + public function clear(string $type): void + { + Cache::forget("reference_data:{$type}"); + } + + /** + * Get all types as an array for convenience. + */ + public function getAllTypes(): array + { + return [ + 'address_types' => $this->getAddressTypes(), + 'phone_types' => $this->getPhoneTypes(), + 'account_types' => $this->getAccountTypes(), + 'contract_types' => $this->getContractTypes(), + ]; + } +} + + diff --git a/app/Services/ReportQueryBuilder.php b/app/Services/ReportQueryBuilder.php new file mode 100644 index 0000000..d2f8fe6 --- /dev/null +++ b/app/Services/ReportQueryBuilder.php @@ -0,0 +1,248 @@ +load(['entities', 'columns', 'conditions', 'orders']); + + // 1. Start with base model query + $query = $this->buildBaseQuery($report); + + // 2. Apply joins from report_entities + $this->applyJoins($query, $report); + + // 3. Select columns from report_columns + $this->applySelects($query, $report); + + // 4. Apply GROUP BY if aggregate functions are used + $this->applyGroupBy($query, $report); + + // 5. Apply conditions from report_conditions + $this->applyConditions($query, $report, $filters); + + // 6. Apply ORDER BY from report_orders + $this->applyOrders($query, $report); + + return $query; + } + + /** + * Build the base query from the first entity. + */ + protected function buildBaseQuery(Report $report): Builder + { + $baseEntity = $report->entities->firstWhere('join_type', 'base'); + + if (!$baseEntity) { + throw new \RuntimeException("Report {$report->slug} has no base entity defined."); + } + + $modelClass = $baseEntity->model_class; + + if (!class_exists($modelClass)) { + throw new \RuntimeException("Model class {$modelClass} does not exist."); + } + + return $modelClass::query(); + } + + /** + * Apply joins from report entities. + */ + protected function applyJoins(Builder $query, Report $report): void + { + $entities = $report->entities->where('join_type', '!=', 'base'); + + foreach ($entities as $entity) { + $table = $this->getTableFromModel($entity->model_class); + + // Use alias if provided + if ($entity->alias) { + $table = "{$table} as {$entity->alias}"; + } + + $joinMethod = $entity->join_type; + + $query->{$joinMethod}( + $table, + $entity->join_first, + $entity->join_operator ?? '=', + $entity->join_second + ); + } + } + + /** + * Apply column selections. + */ + protected function applySelects(Builder $query, Report $report): void + { + $columns = $report->columns + ->where('visible', true) + ->map(fn($col) => DB::raw("{$col->expression} as {$col->key}")) + ->toArray(); + + if (!empty($columns)) { + $query->select($columns); + } + } + + /** + * Apply GROUP BY clause if aggregate functions are detected. + */ + protected function applyGroupBy(Builder $query, Report $report): void + { + $visibleColumns = $report->columns->where('visible', true); + + // Check if any column uses aggregate functions + $hasAggregates = $visibleColumns->contains(function ($col) { + return preg_match('/\b(COUNT|SUM|AVG|MIN|MAX|GROUP_CONCAT)\s*\(/i', $col->expression); + }); + + if (!$hasAggregates) { + return; // No aggregates, no grouping needed + } + + // Find non-aggregate columns that need to be in GROUP BY + $groupByColumns = $visibleColumns + ->filter(function ($col) { + // Check if this column does NOT use an aggregate function + return !preg_match('/\b(COUNT|SUM|AVG|MIN|MAX|GROUP_CONCAT)\s*\(/i', $col->expression); + }) + ->map(function ($col) { + // Extract the actual column expression (before any COALESCE, CAST, etc.) + // For COALESCE(segments.name, 'default'), we need segments.name + if (preg_match('/COALESCE\s*\(\s*([^,]+)\s*,/i', $col->expression, $matches)) { + return trim($matches[1]); + } + // For simple columns, use as-is + return $col->expression; + }) + ->filter() // Remove empty values + ->values() + ->toArray(); + + if (!empty($groupByColumns)) { + foreach ($groupByColumns as $column) { + $query->groupBy(DB::raw($column)); + } + } + } + + /** + * Apply conditions (WHERE clauses). + */ + protected function applyConditions(Builder $query, Report $report, array $filters): void + { + $conditions = $report->conditions->where('enabled', true); + + // Group conditions by group_id + $groups = $conditions->groupBy('group_id'); + + foreach ($groups as $groupId => $groupConditions) { + $query->where(function ($subQuery) use ($groupConditions, $filters) { + foreach ($groupConditions as $condition) { + $value = $this->resolveConditionValue($condition, $filters); + + // Skip if filter-based and no value provided + if ($condition->value_type === 'filter' && $value === null) { + continue; + } + + $method = $condition->logical_operator === 'OR' ? 'orWhere' : 'where'; + + $this->applyCondition($subQuery, $condition, $value, $method); + } + }); + } + } + + /** + * Apply a single condition to the query. + */ + protected function applyCondition(Builder $query, $condition, $value, string $method): void + { + $column = $condition->column; + $operator = strtoupper($condition->operator); + + switch ($operator) { + case 'IS NULL': + $query->{$method . 'Null'}($column); + break; + + case 'IS NOT NULL': + $query->{$method . 'NotNull'}($column); + break; + + case 'IN': + $values = is_array($value) ? $value : explode(',', $value); + $query->{$method . 'In'}($column, $values); + break; + + case 'NOT IN': + $values = is_array($value) ? $value : explode(',', $value); + $query->{$method . 'NotIn'}($column, $values); + break; + + case 'BETWEEN': + if (is_array($value) && count($value) === 2) { + $query->{$method . 'Between'}($column, $value); + } + break; + + case 'LIKE': + $query->{$method}($column, 'LIKE', $value); + break; + + default: + $query->{$method}($column, $operator, $value); + break; + } + } + + /** + * Resolve condition value based on value_type. + */ + protected function resolveConditionValue($condition, array $filters) + { + return match ($condition->value_type) { + 'static' => $condition->value, + 'filter' => $filters[$condition->filter_key] ?? null, + 'expression' => DB::raw($condition->value), + default => null, + }; + } + + /** + * Apply ORDER BY clauses. + */ + protected function applyOrders(Builder $query, Report $report): void + { + foreach ($report->orders as $order) { + $query->orderBy($order->column, $order->direction); + } + } + + /** + * Get table name from model class. + */ + protected function getTableFromModel(string $modelClass): string + { + if (!class_exists($modelClass)) { + throw new \RuntimeException("Model class {$modelClass} does not exist."); + } + + return (new $modelClass)->getTable(); + } +} diff --git a/clean-duplicates.php b/clean-duplicates.php new file mode 100644 index 0000000..8602d0f --- /dev/null +++ b/clean-duplicates.php @@ -0,0 +1,147 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +echo "=== Checking for duplicates ===\n\n"; + +// Check Actions table +echo "ACTIONS TABLE:\n"; +echo "-------------\n"; +$actionDuplicates = DB::table('actions') + ->select('name', DB::raw('COUNT(*) as total_count')) + ->groupBy('name') + ->havingRaw('COUNT(*) > 1') + ->get(); + +if ($actionDuplicates->count() > 0) { + echo "Found duplicate actions:\n"; + foreach ($actionDuplicates as $dup) { + echo " - '{$dup->name}' appears {$dup->total_count} times\n"; + + // Get all IDs for this name + $records = DB::table('actions') + ->where('name', $dup->name) + ->orderBy('id') + ->get(['id', 'name', 'created_at']); + + echo " IDs: "; + foreach ($records as $record) { + echo $record->id . " "; + } + echo "\n"; + } +} else { + echo "No duplicates found.\n"; +} + +echo "\n"; + +// Check Decisions table +echo "DECISIONS TABLE:\n"; +echo "---------------\n"; +$decisionDuplicates = DB::table('decisions') + ->select('name', DB::raw('COUNT(*) as total_count')) + ->groupBy('name') + ->havingRaw('COUNT(*) > 1') + ->get(); + +if ($decisionDuplicates->count() > 0) { + echo "Found duplicate decisions:\n"; + foreach ($decisionDuplicates as $dup) { + echo " - '{$dup->name}' appears {$dup->total_count} times\n"; + + // Get all IDs for this name + $records = DB::table('decisions') + ->where('name', $dup->name) + ->orderBy('id') + ->get(['id', 'name', 'created_at']); + + echo " IDs: "; + foreach ($records as $record) { + echo $record->id . " "; + } + echo "\n"; + } +} else { + echo "No duplicates found.\n"; +} + +echo "\n=== Removing duplicates ===\n\n"; + +// Remove duplicate actions (keep the first one) +if ($actionDuplicates->count() > 0) { + foreach ($actionDuplicates as $dup) { + $records = DB::table('actions') + ->where('name', $dup->name) + ->orderBy('id') + ->get(['id']); + + // Keep the first, delete the rest + $toDelete = $records->skip(1)->pluck('id')->toArray(); + + if (count($toDelete) > 0) { + DB::table('actions')->whereIn('id', $toDelete)->delete(); + echo "Deleted duplicate actions for '{$dup->name}': IDs " . implode(', ', $toDelete) . "\n"; + } + } +} + +// Remove duplicate decisions (keep the first one) +if ($decisionDuplicates->count() > 0) { + foreach ($decisionDuplicates as $dup) { + $records = DB::table('decisions') + ->where('name', $dup->name) + ->orderBy('id') + ->get(['id']); + + // Keep the first, delete the rest + $toDelete = $records->skip(1)->pluck('id')->toArray(); + + if (count($toDelete) > 0) { + DB::table('decisions')->whereIn('id', $toDelete)->delete(); + echo "Deleted duplicate decisions for '{$dup->name}': IDs " . implode(', ', $toDelete) . "\n"; + } + } +} + +echo "\n"; + +// Check and clean action_decision pivot table +echo "ACTION_DECISION PIVOT TABLE:\n"; +echo "---------------------------\n"; + +// Find duplicates in pivot table +$pivotDuplicates = DB::table('action_decision') + ->select('action_id', 'decision_id', DB::raw('COUNT(*) as total_count')) + ->groupBy('action_id', 'decision_id') + ->havingRaw('COUNT(*) > 1') + ->get(); + +if ($pivotDuplicates->count() > 0) { + echo "Found duplicate pivot entries:\n"; + foreach ($pivotDuplicates as $dup) { + echo " - action_id: {$dup->action_id}, decision_id: {$dup->decision_id} appears {$dup->total_count} times\n"; + + // Get all IDs for this combination + $records = DB::table('action_decision') + ->where('action_id', $dup->action_id) + ->where('decision_id', $dup->decision_id) + ->orderBy('id') + ->get(['id']); + + // Keep the first, delete the rest + $toDelete = $records->skip(1)->pluck('id')->toArray(); + + if (count($toDelete) > 0) { + DB::table('action_decision')->whereIn('id', $toDelete)->delete(); + echo " Deleted duplicate pivot entries: IDs " . implode(', ', $toDelete) . "\n"; + } + } +} else { + echo "No duplicates found.\n"; +} + +echo "\n=== Cleanup complete ===\n"; diff --git a/components.json b/components.json new file mode 100644 index 0000000..16e49a0 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "new-york", + "typescript": false, + "tailwind": { + "config": "tailwind.config.js", + "css": "resources/css/app.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/Components", + "utils": "@/lib/utils", + "ui": "@/Components/ui", + "lib": "@/lib", + "composables": "@/composables" + }, + "registries": {} +} diff --git a/composer.json b/composer.json index 1f4b811..78878ab 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "require": { "php": "^8.2", "arielmejiadev/larapex-charts": "^2.1", + "barryvdh/laravel-dompdf": "^3.1", "diglactic/laravel-breadcrumbs": "^10.0", "http-interop/http-factory-guzzle": "^1.2", "inertiajs/inertia-laravel": "^2.0", diff --git a/composer.lock b/composer.lock index a8a9bdd..2238565 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d29c47a4d6813ee8e80a7c8112c2f17e", + "content-hash": "d28e6760b713feea1c4ad6058f96287a", "packages": [ { "name": "arielmejiadev/larapex-charts", @@ -113,6 +113,83 @@ }, "time": "2024-10-01T13:55:55+00:00" }, + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d", + "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11|^12", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7|^3.0", + "orchestra/testbench": "^7|^8|^9|^10", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-02-13T15:07:54+00:00" + }, { "name": "brick/math", "version": "0.12.3", @@ -761,6 +838,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.4", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "db712c90c5b9868df3600e64e68da62e78a34623" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623", + "reference": "db712c90c5b9868df3600e64e68da62e78a34623", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.4" + }, + "time": "2025-10-29T12:43:30+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.1" + }, + "time": "2024-12-02T14:37:59+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0" + }, + "time": "2024-04-29T13:26:35+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.4.0", @@ -2995,16 +3227,16 @@ }, { "name": "maennchen/zipstream-php", - "version": "3.2.1", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416", "shasum": "" }, "require": { @@ -3015,7 +3247,7 @@ "require-dev": { "brianium/paratest": "^7.7", "ext-zip": "*", - "friendsofphp/php-cs-fixer": "^3.86", + "friendsofphp/php-cs-fixer": "^3.16", "guzzlehttp/guzzle": "^7.5", "mikey179/vfsstream": "^1.6", "php-coveralls/php-coveralls": "^2.5", @@ -3061,7 +3293,7 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0" }, "funding": [ { @@ -3069,7 +3301,7 @@ "type": "github" } ], - "time": "2025-12-10T09:58:31+00:00" + "time": "2025-07-17T11:15:13+00:00" }, { "name": "markbaker/complex", @@ -3178,6 +3410,73 @@ }, "time": "2022-12-02T22:17:43+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "meilisearch/meilisearch-php", "version": "v1.13.0", @@ -5031,6 +5330,72 @@ ], "time": "2025-02-28T15:16:05+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v8.9.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9", + "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "require-dev": { + "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41", + "rawr/cross-data-providers": "^2.0.0" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0" + }, + "time": "2025-07-11T13:20:48+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.92.0", diff --git a/config/jetstream.php b/config/jetstream.php index bff61fa..1fd04df 100644 --- a/config/jetstream.php +++ b/config/jetstream.php @@ -60,7 +60,7 @@ 'features' => [ // Features::termsAndPrivacyPolicy(), // Features::profilePhotos(), - Features::api(), + // Features::api(), // Features::teams(['invitations' => true]), Features::accountDeletion(), ], diff --git a/config/reports.php b/config/reports.php new file mode 100644 index 0000000..f40d01a --- /dev/null +++ b/config/reports.php @@ -0,0 +1,10 @@ + [ + // e.g., 'mv_activities_daily', 'mv_segment_activity_counts' + ], + // Time for scheduled refresh (24h format HH:MM) + 'refresh_time' => '03:00', +]; diff --git a/database/migrations/2025_11_02_000000_add_performance_indexes.php b/database/migrations/2025_11_02_000000_add_performance_indexes.php new file mode 100644 index 0000000..389d00e --- /dev/null +++ b/database/migrations/2025_11_02_000000_add_performance_indexes.php @@ -0,0 +1,143 @@ +indexExists('contracts', 'contracts_client_case_id_active_deleted_at_index')) { + $table->index(['client_case_id', 'active', 'deleted_at'], 'contracts_client_case_id_active_deleted_at_index'); + } + if (! $this->indexExists('contracts', 'contracts_start_date_end_date_index')) { + $table->index(['start_date', 'end_date'], 'contracts_start_date_end_date_index'); + } + }); + + // Contract segment pivot table indexes + Schema::table('contract_segment', function (Blueprint $table) { + if (! $this->indexExists('contract_segment', 'contract_segment_contract_id_active_index')) { + $table->index(['contract_id', 'active'], 'contract_segment_contract_id_active_index'); + } + if (! $this->indexExists('contract_segment', 'contract_segment_segment_id_active_index')) { + $table->index(['segment_id', 'active'], 'contract_segment_segment_id_active_index'); + } + }); + + // Activities table indexes + Schema::table('activities', function (Blueprint $table) { + if (! $this->indexExists('activities', 'activities_client_case_id_created_at_index')) { + $table->index(['client_case_id', 'created_at'], 'activities_client_case_id_created_at_index'); + } + if (! $this->indexExists('activities', 'activities_contract_id_created_at_index')) { + $table->index(['contract_id', 'created_at'], 'activities_contract_id_created_at_index'); + } + }); + + // Client cases table indexes + Schema::table('client_cases', function (Blueprint $table) { + if (! $this->indexExists('client_cases', 'client_cases_client_id_active_index')) { + $table->index(['client_id', 'active'], 'client_cases_client_id_active_index'); + } + if (! $this->indexExists('client_cases', 'client_cases_person_id_active_index')) { + $table->index(['person_id', 'active'], 'client_cases_person_id_active_index'); + } + }); + + // Documents table indexes for polymorphic relations + Schema::table('documents', function (Blueprint $table) { + if (! $this->indexExists('documents', 'documents_documentable_type_documentable_id_index')) { + $table->index(['documentable_type', 'documentable_id'], 'documents_documentable_type_documentable_id_index'); + } + if (! $this->indexExists('documents', 'documents_created_at_index')) { + $table->index(['created_at'], 'documents_created_at_index'); + } + }); + + // Field jobs indexes + Schema::table('field_jobs', function (Blueprint $table) { + if (! $this->indexExists('field_jobs', 'field_jobs_assigned_user_id_index')) { + $table->index(['assigned_user_id'], 'field_jobs_assigned_user_id_index'); + } + if (! $this->indexExists('field_jobs', 'field_jobs_contract_id_index')) { + $table->index(['contract_id'], 'field_jobs_contract_id_index'); + } + if (! $this->indexExists('field_jobs', 'field_jobs_completed_at_index')) { + $table->index(['completed_at'], 'field_jobs_completed_at_index'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('contracts', function (Blueprint $table) { + $table->dropIndex('contracts_client_case_id_active_deleted_at_index'); + $table->dropIndex('contracts_start_date_end_date_index'); + }); + + Schema::table('contract_segment', function (Blueprint $table) { + $table->dropIndex('contract_segment_contract_id_active_index'); + $table->dropIndex('contract_segment_segment_id_active_index'); + }); + + Schema::table('activities', function (Blueprint $table) { + $table->dropIndex('activities_client_case_id_created_at_index'); + $table->dropIndex('activities_contract_id_created_at_index'); + }); + + Schema::table('client_cases', function (Blueprint $table) { + $table->dropIndex('client_cases_client_id_active_index'); + $table->dropIndex('client_cases_person_id_active_index'); + }); + + Schema::table('documents', function (Blueprint $table) { + $table->dropIndex('documents_documentable_type_documentable_id_index'); + $table->dropIndex('documents_created_at_index'); + }); + + Schema::table('field_jobs', function (Blueprint $table) { + $table->dropIndex('field_jobs_assigned_user_id_index'); + $table->dropIndex('field_jobs_contract_id_index'); + $table->dropIndex('field_jobs_completed_at_index'); + }); + } + + /** + * Check if an index exists on a table. + */ + protected function indexExists(string $table, string $index): bool + { + $connection = Schema::getConnection(); + $driver = $connection->getDriverName(); + + if ($driver === 'pgsql') { + // PostgreSQL uses pg_indexes system catalog + $result = $connection->select( + "SELECT COUNT(*) as count FROM pg_indexes + WHERE schemaname = 'public' AND tablename = ? AND indexname = ?", + [$table, $index] + ); + } else { + // MySQL/MariaDB uses information_schema.statistics + $databaseName = $connection->getDatabaseName(); + $result = $connection->select( + "SELECT COUNT(*) as count FROM information_schema.statistics + WHERE table_schema = ? AND table_name = ? AND index_name = ?", + [$databaseName, $table, $index] + ); + } + + return $result[0]->count > 0; + } +}; + diff --git a/database/migrations/2025_12_22_201007_add_v2_columns_to_import_entities_table.php b/database/migrations/2025_12_22_201007_add_v2_columns_to_import_entities_table.php new file mode 100644 index 0000000..2f21ab2 --- /dev/null +++ b/database/migrations/2025_12_22_201007_add_v2_columns_to_import_entities_table.php @@ -0,0 +1,32 @@ +string('handler_class')->nullable()->after('meta'); + $table->json('validation_rules')->nullable()->after('handler_class'); + $table->json('processing_options')->nullable()->after('validation_rules'); + $table->boolean('is_active')->default(true)->after('processing_options'); + $table->integer('priority')->default(0)->after('is_active'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('import_entities', function (Blueprint $table) { + $table->dropColumn(['handler_class', 'validation_rules', 'processing_options', 'is_active', 'priority']); + }); + } +}; diff --git a/database/migrations/2025_12_25_000001_create_delete_client_case_function.php b/database/migrations/2025_12_25_000001_create_delete_client_case_function.php new file mode 100644 index 0000000..bba7d76 --- /dev/null +++ b/database/migrations/2025_12_25_000001_create_delete_client_case_function.php @@ -0,0 +1,87 @@ +id(); + $table->string('slug')->unique(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('category', 100)->nullable(); + $table->boolean('enabled')->default(true); + $table->integer('order')->default(0); + $table->timestamps(); + + $table->index('slug'); + $table->index(['enabled', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('reports'); + } +}; diff --git a/database/migrations/2025_12_28_210230_create_report_columns_table.php b/database/migrations/2025_12_28_210230_create_report_columns_table.php new file mode 100644 index 0000000..e4f5bbb --- /dev/null +++ b/database/migrations/2025_12_28_210230_create_report_columns_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('report_id')->constrained()->cascadeOnDelete(); + $table->string('key', 100); + $table->string('label'); + $table->string('type', 50)->default('string'); + $table->text('expression'); + $table->boolean('sortable')->default(true); + $table->boolean('visible')->default(true); + $table->integer('order')->default(0); + $table->json('format_options')->nullable(); + $table->timestamps(); + + $table->index(['report_id', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('report_columns'); + } +}; diff --git a/database/migrations/2025_12_28_210230_create_report_entities_table.php b/database/migrations/2025_12_28_210230_create_report_entities_table.php new file mode 100644 index 0000000..51b4c15 --- /dev/null +++ b/database/migrations/2025_12_28_210230_create_report_entities_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('report_id')->constrained()->cascadeOnDelete(); + $table->string('model_class'); + $table->string('alias', 50)->nullable(); + $table->enum('join_type', ['base', 'join', 'leftJoin', 'rightJoin'])->default('base'); + $table->string('join_first', 100)->nullable(); + $table->string('join_operator', 10)->nullable(); + $table->string('join_second', 100)->nullable(); + $table->integer('order')->default(0); + $table->timestamps(); + + $table->index(['report_id', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('report_entities'); + } +}; diff --git a/database/migrations/2025_12_28_210230_create_report_filters_table.php b/database/migrations/2025_12_28_210230_create_report_filters_table.php new file mode 100644 index 0000000..c27009a --- /dev/null +++ b/database/migrations/2025_12_28_210230_create_report_filters_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('report_id')->constrained()->cascadeOnDelete(); + $table->string('key', 100); + $table->string('label'); + $table->string('type', 50); + $table->boolean('nullable')->default(true); + $table->text('default_value')->nullable(); + $table->json('options')->nullable(); + $table->string('data_source')->nullable(); + $table->integer('order')->default(0); + $table->timestamps(); + + $table->index(['report_id', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('report_filters'); + } +}; diff --git a/database/migrations/2025_12_28_210231_create_report_conditions_table.php b/database/migrations/2025_12_28_210231_create_report_conditions_table.php new file mode 100644 index 0000000..8933ec6 --- /dev/null +++ b/database/migrations/2025_12_28_210231_create_report_conditions_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('report_id')->constrained()->cascadeOnDelete(); + $table->string('column'); + $table->string('operator', 50); + $table->string('value_type', 50); + $table->text('value')->nullable(); + $table->string('filter_key', 100)->nullable(); + $table->enum('logical_operator', ['AND', 'OR'])->default('AND'); + $table->integer('group_id')->nullable(); + $table->integer('order')->default(0); + $table->boolean('enabled')->default(true); + $table->timestamps(); + + $table->index(['report_id', 'group_id', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('report_conditions'); + } +}; diff --git a/database/migrations/2025_12_28_210231_create_report_orders_table.php b/database/migrations/2025_12_28_210231_create_report_orders_table.php new file mode 100644 index 0000000..bbf8179 --- /dev/null +++ b/database/migrations/2025_12_28_210231_create_report_orders_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('report_id')->constrained()->cascadeOnDelete(); + $table->string('column'); + $table->enum('direction', ['ASC', 'DESC'])->default('ASC'); + $table->integer('order')->default(0); + $table->timestamps(); + + $table->index(['report_id', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('report_orders'); + } +}; diff --git a/database/seeders/ImportEntitiesV2Seeder.php b/database/seeders/ImportEntitiesV2Seeder.php new file mode 100644 index 0000000..21be11f --- /dev/null +++ b/database/seeders/ImportEntitiesV2Seeder.php @@ -0,0 +1,306 @@ + 'contracts', + 'canonical_root' => 'contract', + 'label' => 'Pogodbe', + 'fields' => ['reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id', 'meta'], + 'field_aliases' => [], + 'aliases' => ['contract', 'contracts'], + 'supports_multiple' => false, + 'meta' => true, + 'rules' => [], + 'ui' => ['default_field' => 'reference', 'order' => 1], + 'handler_class' => \App\Services\Import\Handlers\ContractHandler::class, + 'validation_rules' => [ + 'reference' => 'required|string|max:255', + ], + 'processing_options' => [ + 'update_mode' => 'update', // update, skip, error + 'create_missing' => true, + 'supports_reactivation' => true, + 'merge_json_fields' => ['meta'], + 'post_actions' => [ + 'attach_segment_id' => null, + 'create_activity' => false, + ], + ], + 'is_active' => true, + 'priority' => 100, // Highest - process first to establish chain + ], + [ + 'key' => 'accounts', + 'canonical_root' => 'account', + 'label' => 'Računi', + 'fields' => ['reference', 'initial_amount', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'], + 'field_aliases' => [], + 'aliases' => ['account', 'accounts'], + 'supports_multiple' => false, + 'meta' => false, + 'rules' => [], + 'ui' => ['default_field' => 'reference', 'order' => 2], + 'handler_class' => \App\Services\Import\Handlers\AccountHandler::class, + 'validation_rules' => [ + 'reference' => 'required|string|max:255', + 'contract_id' => 'required|integer|exists:contracts,id', + ], + 'processing_options' => [ + 'update_mode' => 'update', + 'require_contract' => true, + 'track_balance_changes' => true, + 'create_activity_on_balance_change' => true, + 'history_import' => [ + 'skip_updates' => true, + 'force_zero_balances' => true, + 'auto_create_for_contract' => true, + ], + ], + 'is_active' => true, + 'priority' => 50, // After Person and contacts + ], + [ + 'key' => 'payments', + 'canonical_root' => 'payment', + 'label' => 'Plačila', + 'fields' => [ + 'reference', + 'payment_nu', + 'payment_date', + 'amount', + 'type_id', + 'active', + // optional helpers for mapping by related records + 'debt_id', + 'account_id', + 'account_reference', + 'contract_reference' + ], + 'field_aliases' => [ + 'datum' => 'payment_date', + 'paid_at' => 'payment_date', + 'number' => 'payment_nu', + 'znesek' => 'amount', + 'value' => 'amount' + ], + 'aliases' => ['payment', 'payments'], + 'supports_multiple' => false, + 'meta' => false, + 'rules' => [], + 'ui' => ['default_field' => 'reference', 'order' => 3], + 'handler_class' => \App\Services\Import\Handlers\PaymentHandler::class, + 'validation_rules' => [ + 'amount' => 'required|numeric', + ], + 'processing_options' => [ + 'deduplicate_by' => ['account_id', 'reference'], + 'create_booking' => true, + 'create_activity' => false, // Based on PaymentSetting + 'track_balance' => true, + 'activity_note_template' => 'Prejeto plačilo [amount] [currency]', + 'payments_import' => [ + 'require_fields' => ['contract.reference', 'payment.amount', 'payment.payment_date'], + 'contract_key_mode' => 'reference', + ], + ], + 'is_active' => true, + 'priority' => 40, // After Account + ], + [ + 'key' => 'activities', + 'canonical_root' => 'activity', + 'label' => 'Aktivnosti', + 'fields' => ['client_case_id', 'contract_id', 'due_date', 'amount', 'note', 'action_id', 'decision_id'], + 'field_aliases' => [], + 'aliases' => ['activity', 'activities'], + 'supports_multiple' => false, + 'meta' => false, + 'rules' => [], + 'ui' => ['default_field' => 'note', 'order' => 4], + 'handler_class' => \App\Services\Import\Handlers\ActivityHandler::class, + 'validation_rules' => [], + 'processing_options' => [ + 'require_contract' => false, + 'require_client_case' => false, + ], + 'is_active' => true, + 'priority' => 30, // After all primary entities + ], + [ + 'key' => 'person', + 'canonical_root' => 'person', + 'label' => 'Osebe', + 'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'], + 'field_aliases' => [], + 'aliases' => ['person'], + 'supports_multiple' => false, + 'meta' => false, + 'rules' => [], + 'ui' => ['default_field' => 'full_name', 'order' => 5], + 'handler_class' => \App\Services\Import\Handlers\PersonHandler::class, + 'validation_rules' => [], + 'processing_options' => [ + 'deduplicate_by' => ['tax_number', 'social_security_number'], + 'update_mode' => 'update', + ], + 'is_active' => true, + 'priority' => 90, // Third - derive from Contract/ClientCase chain if exists + ], + [ + 'key' => 'emails', + 'canonical_root' => 'email', + 'label' => 'Email naslovi', + 'fields' => ['value', 'is_primary', 'label'], + 'field_aliases' => [], + 'aliases' => ['email', 'emails'], + 'supports_multiple' => true, + 'meta' => false, + 'rules' => [], + 'ui' => ['default_field' => 'value', 'order' => 6], + 'handler_class' => \App\Services\Import\Handlers\EmailHandler::class, + 'validation_rules' => [ + 'value' => 'required|email', + ], + 'processing_options' => [ + 'deduplicate' => true, + ], + 'is_active' => true, + 'priority' => 80, // After Person + ], + [ + 'key' => 'person_addresses', + 'canonical_root' => 'address', + 'label' => 'Naslovi oseb', + 'fields' => ['address', 'city', 'postal_code', 'country', 'type_id', 'description'], + 'field_aliases' => [ + 'ulica' => 'address', + 'naslov' => 'address', + 'mesto' => 'city', + 'posta' => 'postal_code', + 'pošta' => 'postal_code', + 'zip' => 'postal_code', + 'drzava' => 'country', + 'država' => 'country', + 'opis' => 'description', + ], + 'aliases' => ['person_addresses', 'address', 'addresses'], + 'supports_multiple' => true, + 'meta' => false, + 'rules' => [ + ['pattern' => '/^(naslov|ulica|address)\b/i', 'field' => 'address'], + ['pattern' => '/^(mesto|city|kraj)\b/i', 'field' => 'city'], + ['pattern' => '/^(posta|pošta|zip|postal)\b/i', 'field' => 'postal_code'], + ['pattern' => '/^(drzava|država|country)\b/i', 'field' => 'country'], + ['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'], + ], + 'ui' => ['default_field' => 'address', 'order' => 7], + 'handler_class' => \App\Services\Import\Handlers\AddressHandler::class, + 'validation_rules' => [ + 'address' => 'required|string|max:255', + ], + 'processing_options' => [ + 'deduplicate' => true, + 'parent_entity' => 'person', + ], + 'is_active' => true, + 'priority' => 70, // After Person + ], + [ + 'key' => 'person_phones', + 'canonical_root' => 'phone', + 'label' => 'Telefoni oseb', + 'fields' => ['nu', 'country_code', 'type_id', 'description'], + 'field_aliases' => ['number' => 'nu'], + 'aliases' => ['phone', 'person_phones'], + 'supports_multiple' => true, + 'meta' => false, + 'rules' => [ + ['pattern' => '/^(telefon|tel\.?|gsm|mobile|phone|kontakt)\b/i', 'field' => 'nu'], + ], + 'ui' => ['default_field' => 'nu', 'order' => 8], + 'handler_class' => \App\Services\Import\Handlers\PhoneHandler::class, + 'validation_rules' => [ + 'nu' => 'required|string|max:50', + ], + 'processing_options' => [ + 'deduplicate' => true, + 'parent_entity' => 'person', + ], + 'is_active' => true, + 'priority' => 60, // After Person + ], + [ + 'key' => 'client_cases', + 'canonical_root' => 'client_case', + 'label' => 'Primeri', + 'fields' => ['client_ref'], + 'field_aliases' => [], + 'aliases' => ['client_case', 'client_cases', 'case', 'primeri', 'primer'], + 'supports_multiple' => false, + 'meta' => false, + 'rules' => [ + ['pattern' => '/^(client\s*ref|client_ref|case\s*ref|case_ref|primer|primeri|zadeva)\b/i', 'field' => 'client_ref'], + ], + 'ui' => ['default_field' => 'client_ref', 'order' => 9], + 'handler_class' => \App\Services\Import\Handlers\ClientCaseHandler::class, + 'validation_rules' => [ + 'client_ref' => 'required|string|max:255', + ], + 'processing_options' => [ + 'deduplicate_by' => ['client_ref'], + 'update_mode' => 'update', + ], + 'is_active' => true, + 'priority' => 95, // Second - process after Contract to establish chain + ], + [ + 'key' => 'case_objects', + 'canonical_root' => 'case_object', + 'label' => 'Predmeti', + 'fields' => ['reference', 'name', 'description', 'type', 'contract_id'], + 'field_aliases' => [], + 'aliases' => ['case_object', 'case_objects', 'object', 'objects', 'predmet', 'predmeti'], + 'supports_multiple' => false, + 'meta' => false, + 'rules' => [ + ['pattern' => '/^(sklic|reference|ref)\b/i', 'field' => 'reference'], + ['pattern' => '/^(ime|naziv|name|title)\b/i', 'field' => 'name'], + ['pattern' => '/^(tip|vrsta|type|kind)\b/i', 'field' => 'type'], + ['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'], + ['pattern' => '/^(contract\s*id|contract_id|pogodba\s*id|pogodba_id)\b/i', 'field' => 'contract_id'], + ], + 'ui' => ['default_field' => 'name', 'order' => 10], + 'handler_class' => \App\Services\Import\Handlers\CaseObjectHandler::class, + 'validation_rules' => [ + 'name' => 'required|string|max:255', + ], + 'processing_options' => [ + 'require_contract' => false, + ], + 'is_active' => true, + 'priority' => 10, + ], + ]; + + foreach ($entities as $entity) { + ImportEntity::updateOrCreate( + ['key' => $entity['key']], + $entity + ); + } + + $this->command->info('Import entities v2 seeded successfully.'); + } +} diff --git a/database/seeders/ReportsSeeder.php b/database/seeders/ReportsSeeder.php new file mode 100644 index 0000000..e9c11c3 --- /dev/null +++ b/database/seeders/ReportsSeeder.php @@ -0,0 +1,786 @@ +seedActiveContractsReport(); + $this->seedFieldJobsCompletedReport(); + $this->seedDecisionsCountReport(); + $this->seedSegmentActivityCountsReport(); + $this->seedActionsDecisionsCountReport(); + $this->seedActivitiesPerPeriodReport(); + } + + protected function seedActiveContractsReport(): void + { + $report = Report::create([ + 'slug' => 'active-contracts', + 'name' => 'Aktivne pogodbe', + 'description' => 'Pogodbe, ki so aktivne na izbrani dan, z možnostjo filtriranja po stranki.', + 'category' => 'contracts', + 'enabled' => true, + 'order' => 1, + ]); + + // 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, + ]); + + $report->entities()->create([ + 'model_class' => 'App\\Models\\Client', + 'join_type' => 'leftJoin', + 'join_first' => 'client_cases.client_id', + 'join_operator' => '=', + 'join_second' => 'clients.id', + 'order' => 2, + ]); + + $report->entities()->createMany([ + [ + 'model_class' => 'App\\Models\\Person\\Person', + 'alias' => 'client_people', + 'join_type' => 'leftJoin', + 'join_first' => 'clients.person_id', + 'join_operator' => '=', + 'join_second' => 'client_people.id', + 'order' => 3, + ], + [ + 'model_class' => 'App\\Models\\Person\\Person', + 'alias' => 'subject_people', + 'join_type' => 'leftJoin', + 'join_first' => 'client_cases.person_id', + 'join_operator' => '=', + 'join_second' => 'subject_people.id', + 'order' => 4, + ], + ]); + + $report->entities()->create([ + 'model_class' => 'App\\Models\\Account', + 'join_type' => 'leftJoin', + 'join_first' => 'contracts.id', + 'join_operator' => '=', + 'join_second' => 'accounts.contract_id', + 'order' => 5, + ]); + + // Columns + $report->columns()->createMany([ + [ + 'key' => 'contract_reference', + 'label' => 'Pogodba', + 'type' => 'string', + 'expression' => 'contracts.reference', + 'order' => 0, + ], + [ + 'key' => 'client_name', + 'label' => 'Stranka', + 'type' => 'string', + 'expression' => 'client_people.full_name', + 'order' => 1, + ], + [ + 'key' => 'person_name', + 'label' => 'Zadeva (oseba)', + 'type' => 'string', + 'expression' => 'subject_people.full_name', + 'order' => 2, + ], + [ + 'key' => 'start_date', + 'label' => 'Začetek', + 'type' => 'date', + 'expression' => 'contracts.start_date', + 'order' => 3, + ], + [ + 'key' => 'end_date', + 'label' => 'Konec', + 'type' => 'date', + 'expression' => 'contracts.end_date', + 'order' => 4, + ], + [ + 'key' => 'balance_amount', + 'label' => 'Saldo', + 'type' => 'currency', + 'expression' => 'CAST(accounts.balance_amount AS FLOAT)', + 'order' => 5, + ], + ]); + + // Filters + $report->filters()->create([ + 'key' => 'client_uuid', + 'label' => 'Stranka', + 'type' => 'select:client', + 'nullable' => true, + 'order' => 0, + ]); + + // Conditions - Active as of today + $asOf = 'CURRENT_DATE'; + + // start_date <= as_of (or null) + $report->conditions()->create([ + 'column' => 'contracts.start_date', + 'operator' => '<=', + 'value_type' => 'expression', + 'value' => $asOf, + 'logical_operator' => 'OR', + 'group_id' => 1, + 'order' => 0, + ]); + + $report->conditions()->create([ + 'column' => 'contracts.start_date', + 'operator' => 'IS NULL', + 'value_type' => 'static', + 'logical_operator' => 'OR', + 'group_id' => 1, + 'order' => 1, + ]); + + // end_date >= as_of (or null) + $report->conditions()->create([ + 'column' => 'contracts.end_date', + 'operator' => '>=', + 'value_type' => 'expression', + 'value' => $asOf, + 'logical_operator' => 'OR', + 'group_id' => 2, + 'order' => 0, + ]); + + $report->conditions()->create([ + 'column' => 'contracts.end_date', + 'operator' => 'IS NULL', + 'value_type' => 'static', + 'logical_operator' => 'OR', + 'group_id' => 2, + 'order' => 1, + ]); + + // client_uuid filter condition + $report->conditions()->create([ + 'column' => 'clients.uuid', + 'operator' => '=', + 'value_type' => 'filter', + 'filter_key' => 'client_uuid', + 'logical_operator' => 'AND', + 'group_id' => 3, + 'order' => 0, + ]); + + // Orders + $report->orders()->create([ + 'column' => 'contracts.start_date', + 'direction' => 'ASC', + 'order' => 0, + ]); + } + + protected function seedFieldJobsCompletedReport(): void + { + $report = Report::create([ + 'slug' => 'field-jobs-completed', + 'name' => 'Zaključeni tereni', + 'description' => 'Pregled zaključenih terenov po datumu in uporabniku.', + 'category' => 'field', + 'enabled' => true, + 'order' => 2, + ]); + + // Base entity + $report->entities()->create([ + 'model_class' => 'App\\Models\\FieldJob', + 'join_type' => 'base', + 'order' => 0, + ]); + + // Join contracts table + $report->entities()->create([ + 'model_class' => 'App\\Models\\Contract', + 'join_type' => 'leftJoin', + 'join_first' => 'field_jobs.contract_id', + 'join_operator' => '=', + 'join_second' => 'contracts.id', + 'order' => 1, + ]); + + // Join users table + $report->entities()->create([ + 'model_class' => 'App\\Models\\User', + 'join_type' => 'leftJoin', + 'join_first' => 'field_jobs.assigned_user_id', + 'join_operator' => '=', + 'join_second' => 'users.id', + 'order' => 2, + ]); + + // Columns + $report->columns()->createMany([ + [ + 'key' => 'id', + 'label' => '#', + 'type' => 'number', + 'expression' => 'field_jobs.id', + 'sortable' => true, + 'visible' => true, + 'order' => 0, + ], + [ + 'key' => 'contract_reference', + 'label' => 'Pogodba', + 'type' => 'string', + 'expression' => 'contracts.reference', + 'sortable' => true, + 'visible' => true, + 'order' => 1, + ], + [ + 'key' => 'assigned_user_name', + 'label' => 'Terenski', + 'type' => 'string', + 'expression' => 'users.name', + 'sortable' => true, + 'visible' => true, + 'order' => 2, + ], + [ + 'key' => 'completed_at', + 'label' => 'Zaključeno', + 'type' => 'date', + 'expression' => 'field_jobs.completed_at', + 'sortable' => true, + 'visible' => true, + 'order' => 3, + ], + [ + 'key' => 'notes', + 'label' => 'Opombe', + 'type' => 'string', + 'expression' => 'field_jobs.notes', + 'sortable' => false, + 'visible' => true, + 'order' => 4, + ], + ]); + + // Filters + $report->filters()->createMany([ + [ + 'key' => 'from', + 'label' => 'Od', + 'type' => 'date', + 'nullable' => false, + 'default_value' => now()->startOfMonth()->toDateString(), + 'order' => 0, + ], + [ + 'key' => 'to', + 'label' => 'Do', + 'type' => 'date', + 'nullable' => false, + 'default_value' => now()->toDateString(), + 'order' => 1, + ], + [ + 'key' => 'user_id', + 'label' => 'Uporabnik', + 'type' => 'select:user', + 'nullable' => true, + 'order' => 2, + ], + ]); + + // Conditions + $report->conditions()->createMany([ + [ + 'column' => 'field_jobs.cancelled_at', + 'operator' => 'IS NULL', + 'value_type' => 'static', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 0, + 'enabled' => true, + ], + [ + 'column' => 'field_jobs.completed_at', + 'operator' => 'BETWEEN', + 'value_type' => 'filter', + 'filter_key' => 'from,to', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 1, + 'enabled' => true, + ], + [ + 'column' => 'field_jobs.assigned_user_id', + 'operator' => '=', + 'value_type' => 'filter', + 'filter_key' => 'user_id', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 2, + 'enabled' => true, + ], + ]); + + // Order + $report->orders()->create([ + 'column' => 'field_jobs.completed_at', + 'direction' => 'DESC', + 'order' => 0, + ]); + } + + protected function seedDecisionsCountReport(): void + { + $report = Report::create([ + 'slug' => 'decisions-counts', + 'name' => 'Odločitve – štetje', + 'description' => 'Število aktivnosti po odločitvah v izbranem obdobju.', + 'category' => 'activities', + 'enabled' => true, + 'order' => 3, + ]); + + // Entities + $report->entities()->createMany([ + [ + 'model_class' => 'App\\Models\\Activity', + 'join_type' => 'base', + 'order' => 0, + ], + [ + 'model_class' => 'App\\Models\\Decision', + 'join_type' => 'leftJoin', + 'join_first' => 'activities.decision_id', + 'join_operator' => '=', + 'join_second' => 'decisions.id', + 'order' => 1, + ], + ]); + + // Columns + $report->columns()->createMany([ + [ + 'key' => 'decision_name', + 'label' => 'Odločitev', + 'type' => 'string', + 'expression' => "COALESCE(decisions.name, '—')", + 'sortable' => true, + 'visible' => true, + 'order' => 0, + ], + [ + 'key' => 'activities_count', + 'label' => 'Št. aktivnosti', + 'type' => 'number', + 'expression' => 'COUNT(*)', + 'sortable' => true, + 'visible' => true, + 'order' => 1, + ], + ]); + + // Filters + $report->filters()->createMany([ + [ + 'key' => 'from', + 'label' => 'Od', + 'type' => 'date', + 'nullable' => true, + 'order' => 0, + ], + [ + 'key' => 'to', + 'label' => 'Do', + 'type' => 'date', + 'nullable' => true, + 'order' => 1, + ], + ]); + + // Conditions + $report->conditions()->createMany([ + [ + 'column' => 'activities.created_at', + 'operator' => '>=', + 'value_type' => 'filter', + 'filter_key' => 'from', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 0, + 'enabled' => true, + ], + [ + 'column' => 'activities.created_at', + 'operator' => '<=', + 'value_type' => 'filter', + 'filter_key' => 'to', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 1, + 'enabled' => true, + ], + ]); + + // Order + $report->orders()->create([ + 'column' => 'activities_count', + 'direction' => 'DESC', + 'order' => 0, + ]); + } + + protected function seedSegmentActivityCountsReport(): void + { + $report = Report::create([ + 'slug' => 'segment-activity-counts', + 'name' => 'Aktivnosti po segmentih', + 'description' => 'Število aktivnosti po segmentih v izbranem obdobju (glede na segment dejanja).', + 'category' => 'activities', + 'enabled' => true, + 'order' => 4, + ]); + + // Entities + $report->entities()->createMany([ + [ + 'model_class' => 'App\\Models\\Activity', + 'join_type' => 'base', + 'order' => 0, + ], + [ + 'model_class' => 'App\\Models\\Action', + 'join_type' => 'join', + 'join_first' => 'activities.action_id', + 'join_operator' => '=', + 'join_second' => 'actions.id', + 'order' => 1, + ], + [ + 'model_class' => 'App\\Models\\Segment', + 'join_type' => 'leftJoin', + 'join_first' => 'actions.segment_id', + 'join_operator' => '=', + 'join_second' => 'segments.id', + 'order' => 2, + ], + ]); + + // Columns + $report->columns()->createMany([ + [ + 'key' => 'segment_name', + 'label' => 'Segment', + 'type' => 'string', + 'expression' => "COALESCE(segments.name, 'Brez segmenta')", + 'sortable' => true, + 'visible' => true, + 'order' => 0, + ], + [ + 'key' => 'activities_count', + 'label' => 'Št. aktivnosti', + 'type' => 'number', + 'expression' => 'COUNT(*)', + 'sortable' => true, + 'visible' => true, + 'order' => 1, + ], + ]); + + // Filters + $report->filters()->createMany([ + [ + 'key' => 'from', + 'label' => 'Od', + 'type' => 'date', + 'nullable' => true, + 'order' => 0, + ], + [ + 'key' => 'to', + 'label' => 'Do', + 'type' => 'date', + 'nullable' => true, + 'order' => 1, + ], + ]); + + // Conditions + $report->conditions()->createMany([ + [ + 'column' => 'activities.created_at', + 'operator' => '>=', + 'value_type' => 'filter', + 'filter_key' => 'from', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 0, + 'enabled' => true, + ], + [ + 'column' => 'activities.created_at', + 'operator' => '<=', + 'value_type' => 'filter', + 'filter_key' => 'to', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 1, + 'enabled' => true, + ], + ]); + + // Order + $report->orders()->create([ + 'column' => 'activities_count', + 'direction' => 'DESC', + 'order' => 0, + ]); + } + + protected function seedActionsDecisionsCountReport(): void + { + $report = Report::create([ + 'slug' => 'actions-decisions-counts', + 'name' => 'Dejanja / Odločitve – štetje', + 'description' => 'Število aktivnosti po dejanjih in odločitvah v obdobju.', + 'category' => 'activities', + 'enabled' => true, + 'order' => 5, + ]); + + // Entities + $report->entities()->createMany([ + [ + 'model_class' => 'App\\Models\\Activity', + 'join_type' => 'base', + 'order' => 0, + ], + [ + 'model_class' => 'App\\Models\\Action', + 'join_type' => 'leftJoin', + 'join_first' => 'activities.action_id', + 'join_operator' => '=', + 'join_second' => 'actions.id', + 'order' => 1, + ], + [ + 'model_class' => 'App\\Models\\Decision', + 'join_type' => 'leftJoin', + 'join_first' => 'activities.decision_id', + 'join_operator' => '=', + 'join_second' => 'decisions.id', + 'order' => 2, + ], + ]); + + // Columns + $report->columns()->createMany([ + [ + 'key' => 'action_name', + 'label' => 'Dejanje', + 'type' => 'string', + 'expression' => "COALESCE(actions.name, '—')", + 'sortable' => true, + 'visible' => true, + 'order' => 0, + ], + [ + 'key' => 'decision_name', + 'label' => 'Odločitev', + 'type' => 'string', + 'expression' => "COALESCE(decisions.name, '—')", + 'sortable' => true, + 'visible' => true, + 'order' => 1, + ], + [ + 'key' => 'activities_count', + 'label' => 'Št. aktivnosti', + 'type' => 'number', + 'expression' => 'COUNT(*)', + 'sortable' => true, + 'visible' => true, + 'order' => 2, + ], + ]); + + // Filters + $report->filters()->createMany([ + [ + 'key' => 'from', + 'label' => 'Od', + 'type' => 'date', + 'nullable' => true, + 'order' => 0, + ], + [ + 'key' => 'to', + 'label' => 'Do', + 'type' => 'date', + 'nullable' => true, + 'order' => 1, + ], + ]); + + // Conditions + $report->conditions()->createMany([ + [ + 'column' => 'activities.created_at', + 'operator' => '>=', + 'value_type' => 'filter', + 'filter_key' => 'from', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 0, + 'enabled' => true, + ], + [ + 'column' => 'activities.created_at', + 'operator' => '<=', + 'value_type' => 'filter', + 'filter_key' => 'to', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 1, + 'enabled' => true, + ], + ]); + + // Order + $report->orders()->create([ + 'column' => 'activities_count', + 'direction' => 'DESC', + 'order' => 0, + ]); + } + + protected function seedActivitiesPerPeriodReport(): void + { + $report = Report::create([ + 'slug' => 'activities-per-period', + 'name' => 'Aktivnosti po obdobjih', + 'description' => 'Seštevek aktivnosti po dneh/tednih/mesecih v obdobju.', + 'category' => 'activities', + 'enabled' => true, + 'order' => 6, + ]); + + // Base entity + $report->entities()->create([ + 'model_class' => 'App\\Models\\Activity', + 'join_type' => 'base', + 'order' => 0, + ]); + + // Columns (simplified - period grouping handled in ReportQueryBuilder or controller) + $report->columns()->createMany([ + [ + 'key' => 'period', + 'label' => 'Obdobje', + 'type' => 'string', + 'expression' => 'DATE(activities.created_at)', + 'sortable' => true, + 'visible' => true, + 'order' => 0, + ], + [ + 'key' => 'activities_count', + 'label' => 'Št. aktivnosti', + 'type' => 'number', + 'expression' => 'COUNT(*)', + 'sortable' => true, + 'visible' => true, + 'order' => 1, + ], + ]); + + // Filters + $report->filters()->createMany([ + [ + 'key' => 'from', + 'label' => 'Od', + 'type' => 'date', + 'nullable' => true, + 'order' => 0, + ], + [ + 'key' => 'to', + 'label' => 'Do', + 'type' => 'date', + 'nullable' => true, + 'order' => 1, + ], + [ + 'key' => 'period', + 'label' => 'Obdobje (day/week/month)', + 'type' => 'string', + 'nullable' => false, + 'default_value' => 'day', + 'order' => 2, + ], + ]); + + // Conditions + $report->conditions()->createMany([ + [ + 'column' => 'activities.created_at', + 'operator' => '>=', + 'value_type' => 'filter', + 'filter_key' => 'from', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 0, + 'enabled' => true, + ], + [ + 'column' => 'activities.created_at', + 'operator' => '<=', + 'value_type' => 'filter', + 'filter_key' => 'to', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 1, + 'enabled' => true, + ], + ]); + + // Order + $report->orders()->create([ + 'column' => 'period', + 'direction' => 'ASC', + 'order' => 0, + ]); + } +} diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..e155607 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +# Teren App Deployment Script +# This script handles automated deployment from Gitea + +set -e # Exit on any error + +echo "🚀 Starting deployment..." + +# Configuration +PROJECT_DIR="/var/www/Teren-app" +BRANCH="main" # Change to your production branch +GITEA_REPO="git@your-gitea-server.com:username/Teren-app.git" + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Change to project directory +cd $PROJECT_DIR + +echo "📥 Pulling latest changes from $BRANCH..." +git fetch origin $BRANCH +git reset --hard origin/$BRANCH + +echo "🔧 Copying production environment file..." +if [ ! -f .env ]; then + echo "${RED}❌ .env file not found! Please create it from .env.production.example${NC}" + exit 1 +fi + +echo "🐳 Building and starting Docker containers..." +docker-compose down +docker-compose build --no-cache app +docker-compose up -d + +echo "⏳ Waiting for containers to be healthy..." +sleep 10 + +echo "📦 Installing/updating Composer dependencies..." +docker-compose exec -T app composer install --no-dev --optimize-autoloader --no-interaction + +echo "🎨 Building frontend assets..." +# If you build assets locally or in CI/CD, uncomment: +# npm ci +# npm run build + +echo "🔑 Optimizing Laravel..." +docker-compose exec -T app php artisan config:cache +docker-compose exec -T app php artisan route:cache +docker-compose exec -T app php artisan view:cache +docker-compose exec -T app php artisan event:cache + +echo "📊 Running database migrations..." +docker-compose exec -T app php artisan migrate --force + +echo "🗄️ Clearing old caches..." +docker-compose exec -T app php artisan cache:clear +docker-compose exec -T app php artisan queue:restart + +echo "🔄 Restarting queue workers..." +docker-compose restart app + +echo "${GREEN}✅ Deployment completed successfully!${NC}" + +# Optional: Send notification (Slack, Discord, etc.) +# curl -X POST -H 'Content-type: application/json' \ +# --data '{"text":"🚀 Teren App deployed successfully!"}' \ +# YOUR_WEBHOOK_URL + +# Show running containers +echo "📋 Running containers:" +docker-compose ps diff --git a/docker-compose.yaml.example b/docker-compose.yaml.example new file mode 100644 index 0000000..55ea06c --- /dev/null +++ b/docker-compose.yaml.example @@ -0,0 +1,189 @@ +version: '3.8' + +services: + # Laravel Application + app: + build: + context: . + dockerfile: Dockerfile + args: + - PHP_VERSION=8.4 + container_name: teren-app + restart: unless-stopped + working_dir: /var/www + volumes: + - ./:/var/www + - ./storage:/var/www/storage + - ./bootstrap/cache:/var/www/bootstrap/cache + environment: + - APP_ENV=${APP_ENV:-production} + - APP_DEBUG=${APP_DEBUG:-false} + - DB_CONNECTION=pgsql + - DB_HOST=postgres + - DB_PORT=5432 + - DB_DATABASE=${DB_DATABASE} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis + - REDIS_PORT=6379 + - QUEUE_CONNECTION=redis + - LIBREOFFICE_BIN=/usr/bin/soffice + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - teren-network + # Supervisor runs inside the container (defined in Dockerfile) + # Includes PHP-FPM, Laravel queue workers, and queue-sms workers + + # Nginx Web Server (VPN-only access) + nginx: + image: nginx:alpine + container_name: teren-nginx + restart: unless-stopped + ports: + - "10.13.13.1:80:80" # Only accessible via WireGuard VPN + - "10.13.13.1:443:443" # Only accessible via WireGuard VPN + volumes: + - ./:/var/www + - ./docker/nginx/conf.d:/etc/nginx/conf.d + - ./docker/nginx/ssl:/etc/nginx/ssl + - ./docker/certbot/conf:/etc/letsencrypt + - ./docker/certbot/www:/var/www/certbot + depends_on: + - app + networks: + - teren-network + command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" + + # Certbot for SSL certificates + certbot: + image: certbot/certbot + container_name: teren-certbot + restart: unless-stopped + volumes: + - ./docker/certbot/conf:/etc/letsencrypt + - ./docker/certbot/www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + networks: + - teren-network + + # PostgreSQL Database + postgres: + image: postgres:16-alpine + container_name: teren-postgres + restart: unless-stopped + ports: + - "127.0.0.1:5432:5432" # Only accessible via localhost (or VPN) + environment: + - POSTGRES_DB=${DB_DATABASE} + - POSTGRES_USER=${DB_USERNAME} + - POSTGRES_PASSWORD=${DB_PASSWORD} + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - postgres-data:/var/lib/postgresql/data + - ./docker/postgres/init:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - teren-network + + # pgAdmin - PostgreSQL UI + pgadmin: + image: dpage/pgadmin4:latest + container_name: teren-pgadmin + restart: unless-stopped + ports: + - "127.0.0.1:5050:80" # Only accessible via localhost (or VPN) + environment: + - PGADMIN_DEFAULT_EMAIL=${PGADMIN_EMAIL:-admin@admin.com} + - PGADMIN_DEFAULT_PASSWORD=${PGADMIN_PASSWORD:-admin} + - PGADMIN_CONFIG_SERVER_MODE=True + - PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED=True + volumes: + - pgadmin-data:/var/lib/pgadmin + depends_on: + - postgres + networks: + - teren-network + + # Redis for caching and queues + redis: + image: redis:7-alpine + container_name: teren-redis + restart: unless-stopped + ports: + - "127.0.0.1:6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - teren-network + + # WireGuard VPN with Web UI Dashboard + wireguard: + image: weejewel/wg-easy:latest + container_name: teren-wireguard + restart: unless-stopped + cap_add: + - NET_ADMIN + - SYS_MODULE + environment: + - WG_HOST=${WG_SERVERURL} # Your VPS public IP or domain + - PASSWORD=${WG_UI_PASSWORD} # Password for WireGuard UI + - WG_PORT=51820 + - WG_DEFAULT_ADDRESS=10.13.13.x + - WG_DEFAULT_DNS=1.1.1.1,1.0.0.1 + - WG_MTU=1420 + - WG_PERSISTENT_KEEPALIVE=25 + - WG_ALLOWED_IPS=10.13.13.0/24 + volumes: + - wireguard-data:/etc/wireguard + ports: + - "51820:51820/udp" # WireGuard VPN port (public) + - "51821:51821/tcp" # WireGuard Web UI (public for initial setup, then VPN-only) + sysctls: + - net.ipv4.conf.all.src_valid_mark=1 + - net.ipv4.ip_forward=1 + networks: + - teren-network + + # Portainer - Docker Management UI (VPN-only access) + portainer: + image: portainer/portainer-ce:latest + container_name: teren-portainer + restart: unless-stopped + ports: + - "10.13.13.1:9000:9000" # Portainer UI (VPN-only) + - "10.13.13.1:9443:9443" # Portainer HTTPS (VPN-only) + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - portainer-data:/data + networks: + - teren-network + +networks: + teren-network: + driver: bridge + +volumes: + postgres-data: + driver: local + pgadmin-data: + driver: local + redis-data: + driver: local + wireguard-data: + driver: local + portainer-data: + driver: local diff --git a/docker/nginx/conf.d/app.conf b/docker/nginx/conf.d/app.conf new file mode 100644 index 0000000..bddd8db --- /dev/null +++ b/docker/nginx/conf.d/app.conf @@ -0,0 +1,86 @@ +server { + listen 80; + listen [::]:80; + server_name example.com www.example.com; # Change this to your domain + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name example.com www.example.com; # Change this to your domain + + root /var/www/public; + index index.php index.html; + + # SSL Configuration + ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # Change this + ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # Change this + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Logging + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + # Laravel location configuration + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass app:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_hide_header X-Powered-By; + + # Increase timeouts for long-running requests + fastcgi_read_timeout 300; + fastcgi_send_timeout 300; + } + + location ~ /\.(?!well-known).* { + deny all; + } + + # Deny access to sensitive files + location ~ /\.env { + deny all; + } + + # Cache static assets + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml; + gzip_disable "msie6"; + + client_max_body_size 100M; +} diff --git a/docker/nginx/conf.d/app.local.conf b/docker/nginx/conf.d/app.local.conf new file mode 100644 index 0000000..4e10ee3 --- /dev/null +++ b/docker/nginx/conf.d/app.local.conf @@ -0,0 +1,53 @@ +server { + listen 80; + server_name localhost; + + root /var/www/public; + index index.php index.html; + + # Logging + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + # Laravel location configuration + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass app:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_hide_header X-Powered-By; + + # Increase timeouts for debugging + fastcgi_read_timeout 3600; + fastcgi_send_timeout 3600; + } + + location ~ /\.(?!well-known).* { + deny all; + } + + # Deny access to sensitive files + location ~ /\.env { + deny all; + } + + # Cache static assets + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml; + gzip_disable "msie6"; + + client_max_body_size 100M; +} diff --git a/docker/php/custom.ini b/docker/php/custom.ini new file mode 100644 index 0000000..12a0906 --- /dev/null +++ b/docker/php/custom.ini @@ -0,0 +1,23 @@ +; PHP Custom Configuration for Production + +upload_max_filesize = 100M +post_max_size = 100M +memory_limit = 512M +max_execution_time = 300 +max_input_time = 300 + +; OPcache settings +opcache.enable = 1 +opcache.memory_consumption = 256 +opcache.interned_strings_buffer = 16 +opcache.max_accelerated_files = 20000 +opcache.validate_timestamps = 0 +opcache.save_comments = 1 +opcache.fast_shutdown = 1 + +; Production settings +expose_php = Off +display_errors = Off +display_startup_errors = Off +log_errors = On +error_log = /var/log/php_errors.log diff --git a/docker/supervisor/conf.d/laravel-queue.conf b/docker/supervisor/conf.d/laravel-queue.conf new file mode 100644 index 0000000..31d3706 --- /dev/null +++ b/docker/supervisor/conf.d/laravel-queue.conf @@ -0,0 +1,25 @@ +[program:laravel-queue] +process_name=%(program_name)s_%(process_num)02d +command=/usr/local/bin/php /var/www/artisan queue:work --sleep=3 --tries=3 --timeout=300 --verbose +autostart=true +autorestart=true +user=www +numprocs=2 +redirect_stderr=true +stdout_logfile=/var/www/storage/logs/worker.log +stdout_logfile_maxbytes=20MB +stdout_logfile_backups=10 +stopwaitsecs=360 + +[program:laravel-queue-sms] +process_name=%(program_name)s_%(process_num)02d +command=/usr/local/bin/php /var/www/artisan queue:work --queue=sms --sleep=3 --tries=3 --timeout=90 --verbose +autostart=true +autorestart=true +user=www +numprocs=1 +redirect_stderr=true +stdout_logfile=/var/www/storage/logs/worker-sms.log +stdout_logfile_maxbytes=20MB +stdout_logfile_backups=10 +stopwaitsecs=360 diff --git a/docker/supervisor/conf.d/php-fpm.conf b/docker/supervisor/conf.d/php-fpm.conf new file mode 100644 index 0000000..e1151a7 --- /dev/null +++ b/docker/supervisor/conf.d/php-fpm.conf @@ -0,0 +1,11 @@ +[program:php-fpm] +command=/usr/local/sbin/php-fpm --nodaemonize --fpm-config /usr/local/etc/php-fpm.d/www.conf +autostart=true +autorestart=true +priority=5 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +stdout_events_enabled=true +stderr_events_enabled=true diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf new file mode 100644 index 0000000..a014b68 --- /dev/null +++ b/docker/supervisor/supervisord.conf @@ -0,0 +1,19 @@ +[unix_http_server] +file=/var/run/supervisor.sock +chmod=0700 + +[supervisord] +nodaemon=true +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid +childlogdir=/var/log/supervisor +user=root + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock + +[include] +files = /etc/supervisor/conf.d/*.conf diff --git a/mark-import-failed.php b/mark-import-failed.php new file mode 100644 index 0000000..fbd13bb --- /dev/null +++ b/mark-import-failed.php @@ -0,0 +1,18 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +$uuid = '68c7b8f8-fdf0-4575-9cbc-3ab2b3544d8f'; +$import = \App\Models\Import::where('uuid', $uuid)->first(); + +if ($import) { + $import->status = 'failed'; + $import->save(); + echo "Import {$uuid} marked as failed\n"; + echo "Current status: {$import->status}\n"; +} else { + echo "Import not found\n"; +} diff --git a/package-lock.json b/package-lock.json index b5b52a8..67ca1eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,50 +5,70 @@ "packages": { "": { "dependencies": { - "@fortawesome/fontawesome-svg-core": "^6.6.0", - "@fortawesome/free-brands-svg-icons": "^6.6.0", - "@fortawesome/free-regular-svg-icons": "^6.6.0", - "@fortawesome/free-solid-svg-icons": "^6.6.0", - "@fortawesome/vue-fontawesome": "^3.0.8", + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-brands-svg-icons": "^6.7.2", + "@fortawesome/free-regular-svg-icons": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/vue-fontawesome": "^3.1.2", + "@guolao/vue-monaco-editor": "^1.6.0", "@headlessui/vue": "^1.7.23", - "@heroicons/vue": "^2.1.5", - "@internationalized/date": "^3.9.0", - "@vuepic/vue-datepicker": "^11.0.2", - "apexcharts": "^4.0.0", - "flowbite": "^2.5.2", - "flowbite-vue": "^0.1.6", + "@heroicons/vue": "^2.2.0", + "@internationalized/date": "^3.10.0", + "@tanstack/vue-table": "^8.21.3", + "@unovis/ts": "^1.6.2", + "@unovis/vue": "^1.6.2", + "@vee-validate/zod": "^4.15.1", + "@vuepic/vue-datepicker": "^11.0.3", + "@vueuse/core": "^14.1.0", + "apexcharts": "^4.7.0", + "class-variance-authority": "^0.7.1", + "clean": "^4.0.2", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lodash": "^4.17.21", + "lucide-vue-next": "^0.552.0", "material-design-icons-iconfont": "^6.7.0", + "monaco-editor": "^0.55.1", "preline": "^2.7.0", "quill": "^1.3.7", - "reka-ui": "^2.5.1", + "reka-ui": "^2.7.0", + "tailwind-merge": "^3.4.0", + "tailwindcss-animate": "^1.0.7", "tailwindcss-inner-border": "^0.2.0", "v-calendar": "^3.1.2", + "vee-validate": "^4.15.1", "vue-currency-input": "^3.2.1", - "vue-multiselect": "^3.1.0", - "vue-search-input": "^1.1.16", - "vue3-apexcharts": "^1.7.0", - "vuedraggable": "^4.1.0" + "vue-multiselect": "^3.4.0", + "vue-search-input": "^1.1.19", + "vue-sonner": "^2.0.9", + "vue3-apexcharts": "^1.10.0", + "vuedraggable": "^4.1.0", + "zod": "^3.25.76" }, "devDependencies": { "@inertiajs/vue3": "2.0", "@mdi/js": "^7.4.47", - "@tailwindcss/forms": "^0.5.7", - "@tailwindcss/typography": "^0.5.10", - "@vitejs/plugin-vue": "^6.0.1", - "autoprefixer": "^10.4.16", - "axios": "^1.7.4", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/typography": "^0.5.19", + "@types/node": "^24.10.3", + "@vitejs/plugin-vue": "^6.0.2", + "autoprefixer": "^10.4.22", + "axios": "^1.13.2", "laravel-vite-plugin": "^2.0.1", - "postcss": "^8.4.32", - "tailwindcss": "^3.4.0", - "vite": "^7.1.7", - "vue": "^3.3.13" + "postcss": "^8.5.6", + "tailwindcss": "^4.1.16", + "typescript": "^5.9.3", + "vite": "^7.2.7", + "vue": "^3.3.13", + "vue-tsc": "^3.1.8" } }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -57,6 +77,58 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -67,21 +139,21 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -99,23 +171,149 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/css": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz", + "integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==", + "license": "MIT", + "dependencies": { + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", - "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "cpu": [ "ppc64" ], @@ -130,9 +328,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", - "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "cpu": [ "arm" ], @@ -147,9 +345,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", - "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "cpu": [ "arm64" ], @@ -164,9 +362,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", - "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "cpu": [ "x64" ], @@ -181,9 +379,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", - "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -198,9 +396,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", - "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "cpu": [ "x64" ], @@ -215,9 +413,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", - "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "cpu": [ "arm64" ], @@ -232,9 +430,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", - "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "cpu": [ "x64" ], @@ -249,9 +447,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", - "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "cpu": [ "arm" ], @@ -266,9 +464,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", - "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "cpu": [ "arm64" ], @@ -283,9 +481,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", - "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "cpu": [ "ia32" ], @@ -300,9 +498,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", - "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "cpu": [ "loong64" ], @@ -317,9 +515,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", - "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "cpu": [ "mips64el" ], @@ -334,9 +532,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", - "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "cpu": [ "ppc64" ], @@ -351,9 +549,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", - "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "cpu": [ "riscv64" ], @@ -368,9 +566,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", - "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "cpu": [ "s390x" ], @@ -385,9 +583,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", - "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "cpu": [ "x64" ], @@ -402,9 +600,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", - "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "cpu": [ "arm64" ], @@ -419,9 +617,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", - "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "cpu": [ "x64" ], @@ -436,9 +634,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", - "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "cpu": [ "arm64" ], @@ -453,9 +651,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", - "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", "cpu": [ "x64" ], @@ -470,9 +668,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", - "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "cpu": [ "arm64" ], @@ -487,9 +685,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", - "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "cpu": [ "x64" ], @@ -504,9 +702,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", - "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "cpu": [ "arm64" ], @@ -521,9 +719,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", - "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "cpu": [ "ia32" ], @@ -538,9 +736,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", - "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "cpu": [ "x64" ], @@ -564,12 +762,13 @@ } }, "node_modules/@floating-ui/dom": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.1.1.tgz", - "integrity": "sha512-TpIO93+DIujg3g7SykEAGZMDtbJRrmnYRCNYSjJlvIbGhBjRSNTLVbNeDQBrzy9qDgUbiWdc7KA0uZHZ2tJmiw==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.1.0" + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/utils": { @@ -589,16 +788,6 @@ "vue-demi": ">=0.13.0" } }, - "node_modules/@floating-ui/vue/node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" - } - }, "node_modules/@floating-ui/vue/node_modules/vue-demi": { "version": "0.14.10", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", @@ -692,6 +881,52 @@ "vue": ">= 3.0.0 < 4" } }, + "node_modules/@guolao/vue-monaco-editor": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@guolao/vue-monaco-editor/-/vue-monaco-editor-1.6.0.tgz", + "integrity": "sha512-w2IiJ6eJGGeuIgCK6EKZOAfhHTTUB5aZwslzwGbZ5e89Hb4avx6++GkLTW8p84Sng/arFMjLPPxSBI56cFudyQ==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.6.1", + "vue-demi": "latest" + }, + "peerDependencies": { + "@vue/composition-api": "^1.7.2", + "monaco-editor": ">=0.43.0", + "vue": "^2.6.14 || >=3.0.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@guolao/vue-monaco-editor/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@headlessui/vue": { "version": "1.7.23", "resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz", @@ -743,9 +978,9 @@ } }, "node_modules/@internationalized/date": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.9.0.tgz", - "integrity": "sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz", + "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==", "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" @@ -760,23 +995,6 @@ "@swc/helpers": "^0.5.0" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -787,6 +1005,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -812,6 +1041,75 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-2.0.1.tgz", + "integrity": "sha512-HP6XvfNIzfoMVfyGjBckjiAOQK9WfX0ywdLubuPMPv+Vqf5fj0uCbgBQYpiqcWZT6cbyyRnTSXDheT1ugvF6UQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@mdi/js": { "version": "7.4.47", "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", @@ -819,49 +1117,13 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" + "state-local": "^1.0.6" } }, "node_modules/@popperjs/core": { @@ -875,62 +1137,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.29", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", - "integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", "dev": true, "license": "MIT" }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", - "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", - "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", "cpu": [ "arm" ], @@ -942,9 +1158,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", - "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", "cpu": [ "arm64" ], @@ -956,9 +1172,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", - "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", "cpu": [ "arm64" ], @@ -970,9 +1186,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", - "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", "cpu": [ "x64" ], @@ -984,9 +1200,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", - "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", "cpu": [ "arm64" ], @@ -998,9 +1214,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", - "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", "cpu": [ "x64" ], @@ -1012,9 +1228,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", - "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", "cpu": [ "arm" ], @@ -1026,9 +1242,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", - "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", "cpu": [ "arm" ], @@ -1040,9 +1256,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", - "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", "cpu": [ "arm64" ], @@ -1054,9 +1270,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", - "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", "cpu": [ "arm64" ], @@ -1068,9 +1284,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", - "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", "cpu": [ "loong64" ], @@ -1082,9 +1298,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", - "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", "cpu": [ "ppc64" ], @@ -1096,9 +1312,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", - "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", "cpu": [ "riscv64" ], @@ -1110,9 +1326,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", - "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", "cpu": [ "riscv64" ], @@ -1124,9 +1340,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", - "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", "cpu": [ "s390x" ], @@ -1138,9 +1354,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", - "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", "cpu": [ "x64" ], @@ -1152,9 +1368,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", - "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", "cpu": [ "x64" ], @@ -1166,9 +1382,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", - "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", "cpu": [ "arm64" ], @@ -1180,9 +1396,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", - "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", "cpu": [ "arm64" ], @@ -1194,9 +1410,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", - "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", "cpu": [ "ia32" ], @@ -1208,9 +1424,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", - "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", "cpu": [ "x64" ], @@ -1222,9 +1438,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", - "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", "cpu": [ "x64" ], @@ -1301,9 +1517,9 @@ } }, "node_modules/@tailwindcss/forms": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", - "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", "dev": true, "license": "MIT", "dependencies": { @@ -1313,6 +1529,337 @@ "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, "node_modules/@tailwindcss/typography": { "version": "0.5.19", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", @@ -1326,23 +1873,55 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-core": { - "version": "3.13.12", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", - "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "version": "3.13.13", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.13.tgz", + "integrity": "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/vue-virtual": { - "version": "3.13.12", - "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.12.tgz", - "integrity": "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==", + "node_modules/@tanstack/vue-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz", + "integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.13.12" + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": ">=3.2" + } + }, + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.13", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.13.tgz", + "integrity": "sha512-Cf2xIEE8nWAfsX0N5nihkPYMeQRT+pHt4NEkuP8rNCn6lVnLDiV8rC8IeIxbKmQC0yPnj4SIBLwXYVf86xxKTQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.13" }, "funding": { "type": "github", @@ -1352,16 +1931,360 @@ "vue": "^2.7.0 || ^3.0.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-collection": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@types/d3-collection/-/d3-collection-1.0.13.tgz", + "integrity": "sha512-v0Rgw3IZebRyamcwVmtTDCZ8OmQcj4siaYjNc7wGMZT7PmdSHawGsCOQMxyLvZ7lWjfohYLK0oXtilMOMgfY8A==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.11.2.tgz", + "integrity": "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ==", + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.53", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz", + "integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.7.6.tgz", + "integrity": "sha512-Emkz3V08QnlelSbpT46OEAx+TBZYTOX2r1yM7W+hWg5+djHtQ1GbEXBDRLaqQDOYcDI51Ss0ayoqoKD4CtLUDA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", + "license": "MIT" + }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, + "node_modules/@types/node": { + "version": "24.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", + "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", "license": "MIT" }, "node_modules/@types/resize-observer-browser": { @@ -1370,139 +2293,373 @@ "integrity": "sha512-cNw5iH8JkMkb3QkCoe7DaZiawbDQEUX8t7iuQaRTyLOyQCR2h+ibBD4GJt7p5yhUHrlOeL7ZtbxNHeipqNsBzQ==", "license": "MIT" }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "node_modules/@types/supercluster": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-5.0.3.tgz", + "integrity": "sha512-XMSqQEr7YDuNtFwSgaHHOjsbi0ZGL62V9Js4CW45RBuRYlNWSW/KDqN+RFFE7HdHcGhJPtN0klKvw06r9Kg7rg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/three": { + "version": "0.135.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.135.0.tgz", + "integrity": "sha512-l7WLhIHjhHMtlpyTSltPPAKLpiMwgMD1hXHj59AVUpYRoZP7Fd9NNOSRSvZBCPLpTHPYojgQvSJCoza9zoL7bg==", "license": "MIT" }, + "node_modules/@types/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==", + "license": "MIT" + }, + "node_modules/@types/topojson": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@types/topojson/-/topojson-3.2.6.tgz", + "integrity": "sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-client": "*", + "@types/topojson-server": "*", + "@types/topojson-simplify": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-client": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz", + "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-server": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/topojson-server/-/topojson-server-3.0.4.tgz", + "integrity": "sha512-5+ieK8ePfP+K2VH6Vgs1VCt+fO1U8XZHj0UsF+NktaF0DavAo1q3IvCBXgokk/xmtvoPltSUs6vxuR/zMdOE1g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-simplify": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/topojson-simplify/-/topojson-simplify-3.0.3.tgz", + "integrity": "sha512-sBO5UZ0O2dB0bNwo0vut2yLHhj3neUGi9uL7/ROdm8Gs6dtt4jcB9OGDKr+M2isZwQM2RuzVmifnMZpxj4IGNw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-specification": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz", + "integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/web-bluetooth": { - "version": "0.0.20", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", - "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", "license": "MIT" }, + "node_modules/@unovis/dagre-layout": { + "version": "0.8.8-2", + "resolved": "https://registry.npmjs.org/@unovis/dagre-layout/-/dagre-layout-0.8.8-2.tgz", + "integrity": "sha512-ZfDvfcYtzzhZhgKZty8XDi+zQIotfRqfNVF5M3dFQ9d9C5MTaRdbeBnPUkNrmlLJGgQ42HMOE2ajZLfm2VlRhg==", + "license": "MIT", + "dependencies": { + "@unovis/graphlibrary": "^2.2.0-2", + "lodash-es": "^4.17.21" + } + }, + "node_modules/@unovis/graphlibrary": { + "version": "2.2.0-2", + "resolved": "https://registry.npmjs.org/@unovis/graphlibrary/-/graphlibrary-2.2.0-2.tgz", + "integrity": "sha512-HeEzpd/vDyWiIJt0rnh+2ICXUIuF2N0+Z9OJJiKg0DB+eFUcD+bk+9QPhYHwkFwfxdjDA9fHi1DZ/O/bbV58Nw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + } + }, + "node_modules/@unovis/ts": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@unovis/ts/-/ts-1.6.2.tgz", + "integrity": "sha512-kSOWRiZsU4Kk1yuJSSaJs+zJCKNg3+2A0/6/7/y1Cu5uHggwGPeE3CzKaNOal/Hpc46bnZw2VLI23mY+gDFbeQ==", + "license": "Apache-2.0", + "dependencies": { + "@emotion/css": "^11.7.1", + "@juggle/resize-observer": "^3.3.1", + "@types/d3": "^7.4.0", + "@types/d3-collection": "^1.0.10", + "@types/d3-sankey": "^0.11.2", + "@types/dagre": "^0.7.50", + "@types/geojson": "^7946.0.8", + "@types/leaflet": "1.7.6", + "@types/supercluster": "^5.0.2", + "@types/three": "^0.135.0", + "@types/throttle-debounce": "^5.0.0", + "@types/topojson": "^3.2.3", + "@types/topojson-client": "^3.0.0", + "@types/topojson-specification": "^1.0.2", + "@unovis/dagre-layout": "0.8.8-2", + "@unovis/graphlibrary": "2.2.0-2", + "d3": "^7.2.1", + "d3-collection": "^1.0.7", + "d3-geo-projection": "^4.0.0", + "d3-interpolate-path": "^2.2.3", + "d3-sankey": "^0.12.3", + "elkjs": "^0.10.0", + "geojson": "^0.5.0", + "leaflet": "1.7.1", + "maplibre-gl": "^2.1.9", + "striptags": "^3.2.0", + "supercluster": "^7.1.5", + "three": "^0.135.0", + "throttle-debounce": "^5.0.0", + "to-px": "^1.1.0", + "topojson-client": "^3.1.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@unovis/vue": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@unovis/vue/-/vue-1.6.2.tgz", + "integrity": "sha512-vFglLeiTnNk8VaCEjctAMQXi8Vi+dc217JLkbiIkOLNLXzOClv1ZLv7QjKplRHG+eq/DKC17WSBKINsjXuYbtQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@unovis/ts": "1.6.2", + "vue": "^3" + } + }, + "node_modules/@vee-validate/zod": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/@vee-validate/zod/-/zod-4.15.1.tgz", + "integrity": "sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.8.3", + "vee-validate": "4.15.1" + }, + "peerDependencies": { + "zod": "^3.24.0" + } + }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz", - "integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", + "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.29" + "@rolldown/pluginutils": "1.0.0-beta.53" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, - "node_modules/@vue/compiler-core": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz", - "integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==", + "node_modules/@volar/language-core": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz", + "integrity": "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.4", - "@vue/shared": "3.5.22", - "entities": "^4.5.0", + "@volar/source-map": "2.4.27" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.27.tgz", + "integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.27.tgz", + "integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz", - "integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.22", - "@vue/shared": "3.5.22" + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz", - "integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.4", - "@vue/compiler-core": "3.5.22", - "@vue/compiler-dom": "3.5.22", - "@vue/compiler-ssr": "3.5.22", - "@vue/shared": "3.5.22", + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", "estree-walker": "^2.0.2", - "magic-string": "^0.30.19", + "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz", - "integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.22", - "@vue/shared": "3.5.22" + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.0.tgz", + "integrity": "sha512-CHIuDtZ04CIElAgEuLbwmq3p7QcmYoVPmBPqtdvWJCflZE5W3KHT/5DRBvDv1r2TteCjN02uYHiaAEWq9hQNiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" } }, "node_modules/@vue/reactivity": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz", - "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", + "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.22" + "@vue/shared": "3.5.26" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz", - "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", + "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.22", - "@vue/shared": "3.5.22" + "@vue/reactivity": "3.5.26", + "@vue/shared": "3.5.26" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz", - "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", + "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.22", - "@vue/runtime-core": "3.5.22", - "@vue/shared": "3.5.22", - "csstype": "^3.1.3" + "@vue/reactivity": "3.5.26", + "@vue/runtime-core": "3.5.26", + "@vue/shared": "3.5.26", + "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz", - "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", + "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.22", - "@vue/shared": "3.5.22" + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26" }, "peerDependencies": { - "vue": "3.5.22" + "vue": "3.5.26" } }, "node_modules/@vue/shared": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz", - "integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", "license": "MIT" }, "node_modules/@vuepic/vue-datepicker": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-11.0.2.tgz", - "integrity": "sha512-uHh78mVBXCEjam1uVfTzZ/HkyDwut/H6b2djSN9YTF+l/EA+XONfdCnOVSi1g+qVGSy65DcQAwyBNidAssnudQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-11.0.3.tgz", + "integrity": "sha512-sb2adwqwK2PizLQOpxCYps2SwhVT6/ic2HMIOqHJXuYa6iAJZWGL5YVlS7O4aW+sk6ZyxlDURLO7kDZPL4HB/w==", "license": "MIT", "dependencies": { "date-fns": "^4.1.0" @@ -1515,39 +2672,41 @@ } }, "node_modules/@vueuse/core": { - "version": "12.5.0", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.5.0.tgz", - "integrity": "sha512-GVyH1iYqNANwcahAx8JBm6awaNgvR/SwZ1fjr10b8l1HIgDp82ngNbfzJUgOgWEoxjL+URAggnlilAEXwCOZtg==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.1.0.tgz", + "integrity": "sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==", "license": "MIT", "dependencies": { - "@types/web-bluetooth": "^0.0.20", - "@vueuse/metadata": "12.5.0", - "@vueuse/shared": "12.5.0", - "vue": "^3.5.13" + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.1.0", + "@vueuse/shared": "14.1.0" }, "funding": { "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" } }, "node_modules/@vueuse/metadata": { - "version": "12.5.0", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.5.0.tgz", - "integrity": "sha512-Ui7Lo2a7AxrMAXRF+fAp9QsXuwTeeZ8fIB9wsLHqzq9MQk+2gMYE2IGJW48VMJ8ecvCB3z3GsGLKLbSasQ5Qlg==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.1.0.tgz", + "integrity": "sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" } }, "node_modules/@vueuse/shared": { - "version": "12.5.0", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.5.0.tgz", - "integrity": "sha512-vMpcL1lStUU6O+kdj6YdHDixh0odjPAUM15uJ9f7MY781jcYkIwFA4iv2EfoIPO6vBmvutI1HxxAwmf0cx5ISQ==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.1.0.tgz", + "integrity": "sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==", "license": "MIT", - "dependencies": { - "vue": "^3.5.13" - }, "funding": { "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" } }, "node_modules/@yr/monotone-cubic-spline": { @@ -1556,61 +2715,13 @@ "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", "license": "MIT" }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "node_modules/alien-signals": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.1.tgz", + "integrity": "sha512-ogkIWbVrLwKtHY6oOAXaYkAxP+cTH7V5FZ5+Tm4NZFd8VDZ6uNMDrfzqctTZ42eTMCSR3ne3otpcxmqSnFfPYA==", + "dev": true, "license": "MIT" }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/apexcharts": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-4.7.0.tgz", @@ -1625,12 +2736,6 @@ "@yr/monotone-cubic-spline": "^1.0.3" } }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "license": "MIT" - }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -1643,6 +2748,12 @@ "node": ">=10" } }, + "node_modules/async": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", + "integrity": "sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1651,9 +2762,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", "dev": true, "funding": [ { @@ -1671,10 +2782,9 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, @@ -1689,9 +2799,9 @@ } }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "dev": true, "license": "MIT", "dependencies": { @@ -1700,59 +2810,44 @@ "proxy-from-env": "^1.1.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz", - "integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==", + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", "license": "MIT", - "engines": { - "node": ">=8" - }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" + "url": "https://github.com/sponsors/antfu" } }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -1770,11 +2865,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -1830,19 +2925,19 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=6" } }, "node_modules/caniuse-lite": { - "version": "1.0.30001746", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz", - "integrity": "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==", + "version": "1.0.30001761", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", + "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", "dev": true, "funding": [ { @@ -1860,48 +2955,30 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" + "clsx": "^2.1.1" }, "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "url": "https://polar.sh/cva" } }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", + "node_modules/clean": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/clean/-/clean-4.0.2.tgz", + "integrity": "sha512-2LGVh4dNtI16L4UzqDHO6Hbl74YjG1vWvEUU78dgLO4kuyqJZFMNMPBx+EGtYKTFb14e24p+gWXgkabqxc1EUw==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" + "async": "^0.9.0", + "minimist": "^1.1.0", + "mix2": "^1.0.0", + "skema": "^1.0.0" } }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, "node_modules/clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", @@ -1911,24 +2988,15 @@ "node": ">=0.8" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, "engines": { - "node": ">=7.0.0" + "node": ">=6" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1943,32 +3011,71 @@ } }, "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", "engines": { "node": ">= 6" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -1978,11 +3085,485 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-projection": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz", + "integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==", + "license": "ISC", + "dependencies": { + "commander": "7", + "d3-array": "1 - 3", + "d3-geo": "1.12.0 - 3" + }, + "bin": { + "geo2svg": "bin/geo2svg.js", + "geograticule": "bin/geograticule.js", + "geoproject": "bin/geoproject.js", + "geoquantize": "bin/geoquantize.js", + "geostitch": "bin/geostitch.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate-path": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-interpolate-path/-/d3-interpolate-path-2.3.0.tgz", + "integrity": "sha512-tZYtGXxBmbgHsIc9Wms6LS5u4w6KbP8C09a4/ZYc4KLMYYqub57rRBUgpUr2CIarIrJEpdAWWxWQvofgaMpbKQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -1993,6 +3574,23 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/deep-equal": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", @@ -2013,15 +3611,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2062,6 +3651,15 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2072,17 +3670,24 @@ "node": ">=0.4.0" } }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "license": "Apache-2.0" + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "license": "MIT" + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -2098,29 +3703,43 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" }, "node_modules/electron-to-chromium": { - "version": "1.5.228", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.228.tgz", - "integrity": "sha512-nxkiyuqAn4MJ1QbobwqJILiDtu/jk14hEAWaMiJmNPh1Z+jqoFlBFZjdXwLWGeVSeu9hGLg6+2G9yJaW8rBIFA==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true, "license": "ISC" }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" + "node_modules/elkjs": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.10.2.tgz", + "integrity": "sha512-Yx3ORtbAFrXelYkAy2g0eYyVY8QG0XEmGdQXmy0eithKKjbWRfl3Xe884lfkszfBF6UKyIy4LwfcZ3AZc8oxFw==", + "license": "EPL-2.0" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -2129,6 +3748,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2176,9 +3804,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.39.10", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", - "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz", + "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==", "dev": true, "license": "MIT", "workspaces": [ @@ -2187,9 +3815,9 @@ ] }, "node_modules/esbuild": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", - "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2200,32 +3828,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.10", - "@esbuild/android-arm": "0.25.10", - "@esbuild/android-arm64": "0.25.10", - "@esbuild/android-x64": "0.25.10", - "@esbuild/darwin-arm64": "0.25.10", - "@esbuild/darwin-x64": "0.25.10", - "@esbuild/freebsd-arm64": "0.25.10", - "@esbuild/freebsd-x64": "0.25.10", - "@esbuild/linux-arm": "0.25.10", - "@esbuild/linux-arm64": "0.25.10", - "@esbuild/linux-ia32": "0.25.10", - "@esbuild/linux-loong64": "0.25.10", - "@esbuild/linux-mips64el": "0.25.10", - "@esbuild/linux-ppc64": "0.25.10", - "@esbuild/linux-riscv64": "0.25.10", - "@esbuild/linux-s390x": "0.25.10", - "@esbuild/linux-x64": "0.25.10", - "@esbuild/netbsd-arm64": "0.25.10", - "@esbuild/netbsd-x64": "0.25.10", - "@esbuild/openbsd-arm64": "0.25.10", - "@esbuild/openbsd-x64": "0.25.10", - "@esbuild/openharmony-arm64": "0.25.10", - "@esbuild/sunos-x64": "0.25.10", - "@esbuild/win32-arm64": "0.25.10", - "@esbuild/win32-ia32": "0.25.10", - "@esbuild/win32-x64": "0.25.10" + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/escalade": { @@ -2238,6 +3866,18 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -2256,42 +3896,11 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } + "node_modules/fast-diff": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", + "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==", + "license": "Apache-2.0" }, "node_modules/fdir": { "version": "6.5.0", @@ -2311,81 +3920,11 @@ } } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/floating-vue": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/floating-vue/-/floating-vue-5.2.2.tgz", - "integrity": "sha512-afW+h2CFafo+7Y9Lvw/xsqjaQlKLdJV7h1fCHfcYQ1C4SVMlu7OAekqWgu5d4SgvkBVU0pVpLlVsrSTBURFRkg==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "~1.1.1", - "vue-resize": "^2.0.0-alpha.1" - }, - "peerDependencies": { - "@nuxt/kit": "^3.2.0", - "vue": "^3.2.0" - }, - "peerDependenciesMeta": { - "@nuxt/kit": { - "optional": true - } - } - }, - "node_modules/flowbite": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-2.5.2.tgz", - "integrity": "sha512-kwFD3n8/YW4EG8GlY3Od9IoKND97kitO+/ejISHSqpn3vw2i5K/+ZI8Jm2V+KC4fGdnfi0XZ+TzYqQb4Q1LshA==", - "license": "MIT", - "dependencies": { - "@popperjs/core": "^2.9.3", - "flowbite-datepicker": "^1.3.0", - "mini-svg-data-uri": "^1.4.3" - } - }, - "node_modules/flowbite-datepicker": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/flowbite-datepicker/-/flowbite-datepicker-1.3.2.tgz", - "integrity": "sha512-6Nfm0MCVX3mpaR7YSCjmEO2GO8CDt6CX8ZpQnGdeu03WUCWtEPQ/uy0PUiNtIJjJZWnX0Cm3H55MOhbD1g+E/g==", - "license": "MIT", - "dependencies": { - "@rollup/plugin-node-resolve": "^15.2.3", - "flowbite": "^2.0.0" - } - }, - "node_modules/flowbite-vue": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/flowbite-vue/-/flowbite-vue-0.1.9.tgz", - "integrity": "sha512-0fLA2k8a+U/cFoSoAR7Cs1yvSpNSudB709/hiCVImdbYuhj2lIupbdL3rblULQt+lwYnLwuVb5R/UE9GMGxY4w==", - "license": "MIT", - "dependencies": { - "@vueuse/core": "12.5.0", - "classnames": "2.5.1", - "floating-vue": "^5.2.2", - "flowbite": "2.5.2", - "lodash-es": "^4.17.21", - "nanoid": "5.0.9", - "tailwind-merge": "2.6.0", - "tailwindcss": "^3" - }, - "engines": { - "node": ">=18.x", - "npm": ">=10.x" - }, - "peerDependencies": { - "tailwindcss": "^3", - "vue": "^3.4.x" - } + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" }, "node_modules/follow-redirects": { "version": "1.15.11", @@ -2408,26 +3947,10 @@ } } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, "license": "MIT", "dependencies": { @@ -2442,16 +3965,16 @@ } }, "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, "license": "MIT", "engines": { "node": "*" }, "funding": { - "type": "patreon", + "type": "github", "url": "https://github.com/sponsors/rawify" } }, @@ -2459,6 +3982,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2487,6 +4011,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/geojson": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/geojson/-/geojson-0.5.0.tgz", + "integrity": "sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "license": "ISC" + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2524,36 +4063,36 @@ "node": ">= 0.4" } }, - "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "license": "ISC", + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" }, "engines": { - "node": ">=10.13.0" + "node": ">=6" } }, "node_modules/gopd": { @@ -2568,6 +4107,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -2619,6 +4165,75 @@ "node": ">= 0.4" } }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-arguments": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -2635,17 +4250,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" }, "node_modules/is-core-module": { "version": "2.16.1", @@ -2678,51 +4287,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "license": "MIT" - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -2741,34 +4305,71 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, "license": "MIT", "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, "node_modules/laravel-vite-plugin": { @@ -2791,16 +4392,271 @@ "vite": "^7.0.0" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "license": "MIT", + "node_modules/leaflet": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.7.1.tgz", + "integrity": "sha512-/xwPEBidtg69Q3HlqPdU3DnrXQOvQU/CCHA1tcDQVzOwm91YMYaILjNp7L4Eaw5Z4sOYdbBz6koWyibppd8Zqw==", + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">=14" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lines-and-columns": { @@ -2816,26 +4672,83 @@ "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", "license": "MIT" }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "node_modules/lucide-vue-next": { + "version": "0.552.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.552.0.tgz", + "integrity": "sha512-xbP3UBwNkGoCl1ezW/zsGHtyJ1rOowPmSpUG1f7V38YvZBlGiV3BxL+4mu9C9i0EjfJ6ca066FpnB3VK+HN92g==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-array": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/make-array/-/make-array-0.1.2.tgz", + "integrity": "sha512-bcFmxgZ+OTaMYJp/w6eifElKTcfum7Gi5H7vQ8KzAf9X6swdxkVuilCaG3ZjXr/qJsQT4JJ2Rq9SDYScWEdu9Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/maplibre-gl": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-2.4.0.tgz", + "integrity": "sha512-csNFylzntPmHWidczfgCZpvbTSmhaWvLRj9e1ezUDBEPizGgshgm3ea1T5TCNEEBq0roauu7BPuRZjA3wO4KqA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^2.0.1", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.5", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@types/geojson": "^7946.0.10", + "@types/mapbox__point-geometry": "^0.1.2", + "@types/mapbox__vector-tile": "^1.3.0", + "@types/pbf": "^3.0.2", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.4", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.4.3", + "global-prefix": "^3.0.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.2", + "quickselect": "^2.0.0", + "supercluster": "^7.1.5", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.3" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/material-design-icons-iconfont": { "version": "6.7.0", "resolved": "https://registry.npmjs.org/material-design-icons-iconfont/-/material-design-icons-iconfont-6.7.0.tgz", @@ -2851,40 +4764,6 @@ "node": ">= 0.4" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2912,50 +4791,69 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, "license": "MIT", "bin": { "mini-svg-data-uri": "cli.js" } }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mix2": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/mix2/-/mix2-1.0.5.tgz", + "integrity": "sha512-ybWz7nY+WHBBIyliND5eYaJKzkoa+qXRYNTmVqAxSLlFtL/umT2iv+pmyTu1oU7WNkrirwheqR8d9EaKVz0e5g==", + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=0.10.0" } }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" + "dompurify": "3.2.7", + "marked": "14.0.0" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", - "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -2964,56 +4862,19 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.js" + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": "^18 || >=20" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, "node_modules/node-releases": { - "version": "2.0.21", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", - "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -3058,49 +4919,89 @@ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "license": "MIT" }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/parchment": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz", "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==", "license": "BSD-3-Clause" }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-unit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-unit/-/parse-unit-1.0.1.tgz", + "integrity": "sha512-hrqldJHokR3Qj88EIlV/kAyAi/G5R2+R56TBANxNMy0uPlYcttx0jnMW6Yx5KsKPSbC3KddM/7qQm3+0wEXKxg==", + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=8" } }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3111,6 +5012,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3119,24 +5021,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3165,128 +5049,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-nested/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-selector-parser": { "version": "6.0.10", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", @@ -3305,25 +5067,14 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, "license": "MIT" }, - "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" }, "node_modules/preline": { "version": "2.7.0", @@ -3334,6 +5085,12 @@ "@popperjs/core": "^2.11.2" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3357,25 +5114,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" }, "node_modules/quill": { "version": "1.3.7", @@ -3391,13 +5134,7 @@ "quill-delta": "^3.6.2" } }, - "node_modules/quill/node_modules/fast-diff": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", - "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==", - "license": "Apache-2.0" - }, - "node_modules/quill/node_modules/quill-delta": { + "node_modules/quill-delta": { "version": "3.6.3", "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz", "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==", @@ -3411,39 +5148,6 @@ "node": ">=0.10" } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -3465,9 +5169,9 @@ } }, "node_modules/reka-ui": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.1.tgz", - "integrity": "sha512-QJGB3q21wQ1Kw28HhhNDpjfFe8qpePX1gK4FTBRd68XTh9aEnhR5bTJnlV0jxi8FBPh0xivZBeNFUc3jiGx7mQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.7.0.tgz", + "integrity": "sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13", @@ -3485,23 +5189,49 @@ "vue": ">= 3.2.0" } }, - "node_modules/reka-ui/node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "node_modules/reka-ui/node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/reka-ui/node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/reka-ui/node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" } }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -3515,21 +5245,41 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "license": "MIT", "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/rollup": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", - "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", - "devOptional": true, + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -3542,53 +5292,42 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.3", - "@rollup/rollup-android-arm64": "4.52.3", - "@rollup/rollup-darwin-arm64": "4.52.3", - "@rollup/rollup-darwin-x64": "4.52.3", - "@rollup/rollup-freebsd-arm64": "4.52.3", - "@rollup/rollup-freebsd-x64": "4.52.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", - "@rollup/rollup-linux-arm-musleabihf": "4.52.3", - "@rollup/rollup-linux-arm64-gnu": "4.52.3", - "@rollup/rollup-linux-arm64-musl": "4.52.3", - "@rollup/rollup-linux-loong64-gnu": "4.52.3", - "@rollup/rollup-linux-ppc64-gnu": "4.52.3", - "@rollup/rollup-linux-riscv64-gnu": "4.52.3", - "@rollup/rollup-linux-riscv64-musl": "4.52.3", - "@rollup/rollup-linux-s390x-gnu": "4.52.3", - "@rollup/rollup-linux-x64-gnu": "4.52.3", - "@rollup/rollup-linux-x64-musl": "4.52.3", - "@rollup/rollup-openharmony-arm64": "4.52.3", - "@rollup/rollup-win32-arm64-msvc": "4.52.3", - "@rollup/rollup-win32-ia32-msvc": "4.52.3", - "@rollup/rollup-win32-x64-gnu": "4.52.3", - "@rollup/rollup-win32-x64-msvc": "4.52.3", + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" }, "node_modules/set-function-length": { "version": "1.2.2", @@ -3622,27 +5361,6 @@ "node": ">= 0.4" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -3719,16 +5437,15 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node_modules/skema": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/skema/-/skema-1.0.2.tgz", + "integrity": "sha512-5LWfF2RSW2B3xfOaY6j49X8aNwsnj9cRVrM5QMF7it+cZvpv5ufiOUT13ps2U52sIbAzs11bdRP6mi5qyg75VQ==", + "license": "MIT", + "dependencies": { + "async": "^0.9.0", + "make-array": "^0.1.2", + "mix2": "^1.0.0" } }, "node_modules/sortablejs": { @@ -3737,6 +5454,15 @@ "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==", "license": "MIT" }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3746,122 +5472,52 @@ "node": ">=0.10.0" } }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "license": "MIT" }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", + "node_modules/striptags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/striptags/-/striptags-3.2.0.tgz", + "integrity": "sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "kdbush": "^3.0.0" } }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "copy-anything": "^4" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16" } }, "node_modules/supports-preserve-symlinks-flag": { @@ -3877,9 +5533,9 @@ } }, "node_modules/tailwind-merge": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", - "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "license": "MIT", "funding": { "type": "github", @@ -3887,40 +5543,18 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", - "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" } }, "node_modules/tailwindcss-inner-border": { @@ -3936,38 +5570,33 @@ "tailwindcss": ">=3" } }, - "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, "engines": { - "node": ">=4" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } + "node_modules/three": { + "version": "0.135.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.135.0.tgz", + "integrity": "sha512-kuEpuuxRzLv0MDsXai9huCxOSQPZ4vje6y0gn80SRmQvgz6/+rI0NAvCRAw56zYaWKMGMfqKWsxF9Qa2Z9xymQ==", + "license": "MIT" }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, "engines": { - "node": ">=0.8" + "node": ">=12.22" } }, "node_modules/tinyglobby": { @@ -3987,23 +5616,40 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, + "node_modules/to-px": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/to-px/-/to-px-1.1.0.tgz", + "integrity": "sha512-bfg3GLYrGoEzrGoE05TAL/Uw+H/qrf2ptr9V3W7U0lkjjyYnIfgxmVLUfhQ1hZpIQwin81uxhDjvUkDYsC0xWw==", "license": "MIT", "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" + "parse-unit": "^1.0.1" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "license": "Apache-2.0" + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/topojson-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" }, "node_modules/tslib": { "version": "2.8.1", @@ -4011,10 +5657,43 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -4046,6 +5725,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/v-calendar": { @@ -4091,14 +5771,27 @@ "date-fns": "2.x" } }, + "node_modules/vee-validate": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/vee-validate/-/vee-validate-4.15.1.tgz", + "integrity": "sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.5.2", + "type-fest": "^4.8.3" + }, + "peerDependencies": { + "vue": "^3.4.26" + } + }, "node_modules/vite": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", - "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -4190,17 +5883,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vue": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz", - "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==", + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.22", - "@vue/compiler-sfc": "3.5.22", - "@vue/runtime-dom": "3.5.22", - "@vue/server-renderer": "3.5.22", - "@vue/shared": "3.5.22" + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, + "node_modules/vue": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", + "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-sfc": "3.5.26", + "@vue/runtime-dom": "3.5.26", + "@vue/server-renderer": "3.5.26", + "@vue/shared": "3.5.26" }, "peerDependencies": { "typescript": "*" @@ -4212,33 +5923,24 @@ } }, "node_modules/vue-currency-input": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/vue-currency-input/-/vue-currency-input-3.2.1.tgz", - "integrity": "sha512-Osfxzdu5cdZSCS4Cm0vuk7LwNeSdHWGIWK8gtDBC1kU0UtAKz7iU/8dyJ0KDJKxbAYiKeovoQTRfYxCH82I0EA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/vue-currency-input/-/vue-currency-input-3.2.2.tgz", + "integrity": "sha512-+F6y+cQVJ1zlXfMKVnwf+e8/G8nef6iZOiXLkZbeIq7WiqlNhakAQpadIaIvApL/ZVTMWE0w/If02xYzJ28+5g==", "license": "MIT", "peerDependencies": { "vue": "^2.7 || ^3.0.0" } }, "node_modules/vue-multiselect": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-3.3.1.tgz", - "integrity": "sha512-QZPxG60HK4HCeBNq4rkpzHSzh3ow8blipZbKmYdRvN65If/aFWO/Bzz6eUCED4LQNYlvXG7UJuiFlbqFkAeKXg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-3.4.0.tgz", + "integrity": "sha512-NtaL1/VOsGsvM+H0EZswy1E+RcA5yeCpCxrhT28CBwKu9D1WbMSXBfIDNDw0v+T4enVRfOD5X7u2gyIJWCFX9w==", "license": "MIT", "engines": { "node": ">= 14.18.1", "npm": ">= 6.14.15" } }, - "node_modules/vue-resize": { - "version": "2.0.0-alpha.1", - "resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz", - "integrity": "sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==", - "license": "MIT", - "peerDependencies": { - "vue": "^3.0.0" - } - }, "node_modules/vue-screen-utils": { "version": "1.0.0-beta.13", "resolved": "https://registry.npmjs.org/vue-screen-utils/-/vue-screen-utils-1.0.0-beta.13.tgz", @@ -4249,16 +5951,55 @@ } }, "node_modules/vue-search-input": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/vue-search-input/-/vue-search-input-1.1.18.tgz", - "integrity": "sha512-cQ4XVNLOKywZr7XAYqvhxhTuY/Fka3USfVOw1FAtD6KHzVzZ6+/wGQw8ukm5wWsbJVh2FNOUsmnn+PA174g+eA==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/vue-search-input/-/vue-search-input-1.1.19.tgz", + "integrity": "sha512-sqa9/PqBpdQsymF14fe2eiuhCUsWT3w+jIbQS+eHI5Un4zHKVqFlwSNeoz2hIMhMqKjSB6LB5yi3/L+aPoBuig==", "license": "MIT" }, - "node_modules/vue3-apexcharts": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.8.0.tgz", - "integrity": "sha512-5tSD4mXTBbIJ9ir+58qHE6oNtIe0RNgqIRYMKpcsIaxkKtwUww4JhvPkpUFlmiW4OJbbdklgjleXq1lfcM4gdA==", + "node_modules/vue-sonner": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-2.0.9.tgz", + "integrity": "sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw==", "license": "MIT", + "peerDependencies": { + "@nuxt/kit": "^4.0.3", + "@nuxt/schema": "^4.0.3", + "nuxt": "^4.0.3" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@nuxt/schema": { + "optional": true + }, + "nuxt": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.0.tgz", + "integrity": "sha512-NFhcKKQZeTuG8/gc8XwFANx/lC0Dd3dCZ97TWh1a63PcD22KkFy4QLeT8JMtduaQT1NzySWmx3qXm16Hj1Xsxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.27", + "@vue/language-core": "3.2.0" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vue3-apexcharts": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.10.0.tgz", + "integrity": "sha512-sBma2In4rU5n/JBrv8KVb8if+IoY019Dse2yRDD/eRU1WGZHK07zuy9erefKzbJ7T3wP9+Jsy9bH6Vdjy85HZg==", + "license": "see LICENSE in LICENSE", "peerDependencies": { "apexcharts": ">=4.0.0", "vue": ">=3.0.0" @@ -4277,109 +6018,42 @@ } }, "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" + "which": "bin/which" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" }, "engines": { - "node": ">=12" + "node": ">= 14.6" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "url": "https://github.com/sponsors/colinhacks" } } } diff --git a/package.json b/package.json index dc8fe0c..dbe40cc 100644 --- a/package.json +++ b/package.json @@ -3,46 +3,66 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build" + "build": "vite build", + "typecheck": "vue-tsc --noEmit -p tsconfig.json" }, "devDependencies": { "@inertiajs/vue3": "2.0", "@mdi/js": "^7.4.47", - "@tailwindcss/forms": "^0.5.7", - "@tailwindcss/typography": "^0.5.10", - "@vitejs/plugin-vue": "^6.0.1", - "autoprefixer": "^10.4.16", - "axios": "^1.7.4", - "laravel-vite-plugin": "^2.0.1", - "postcss": "^8.4.32", - "tailwindcss": "^3.4.0", - "vite": "^7.1.7", - "vue": "^3.3.13" + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/typography": "^0.5.19", + "@types/node": "^24.10.3", + "@vitejs/plugin-vue": "^6.0.2", + "autoprefixer": "^10.4.22", + "axios": "^1.13.2", + "laravel-vite-plugin": "^2.0.1", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.16", + "typescript": "^5.9.3", + "vite": "^7.2.7", + "vue": "^3.3.13", + "vue-tsc": "^3.1.8" }, "dependencies": { - "@fortawesome/fontawesome-svg-core": "^6.6.0", - "@fortawesome/free-brands-svg-icons": "^6.6.0", - "@fortawesome/free-regular-svg-icons": "^6.6.0", - "@fortawesome/free-solid-svg-icons": "^6.6.0", - "@fortawesome/vue-fontawesome": "^3.0.8", - "quill": "^1.3.7", + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-brands-svg-icons": "^6.7.2", + "@fortawesome/free-regular-svg-icons": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/vue-fontawesome": "^3.1.2", + "@guolao/vue-monaco-editor": "^1.6.0", "@headlessui/vue": "^1.7.23", - "@heroicons/vue": "^2.1.5", - "@internationalized/date": "^3.9.0", - "@vuepic/vue-datepicker": "^11.0.2", - "apexcharts": "^4.0.0", - "flowbite": "^2.5.2", - "flowbite-vue": "^0.1.6", + "@heroicons/vue": "^2.2.0", + "@internationalized/date": "^3.10.0", + "@tanstack/vue-table": "^8.21.3", + "@unovis/ts": "^1.6.2", + "@unovis/vue": "^1.6.2", + "@vee-validate/zod": "^4.15.1", + "@vuepic/vue-datepicker": "^11.0.3", + "@vueuse/core": "^14.1.0", + "apexcharts": "^4.7.0", + "class-variance-authority": "^0.7.1", + "clean": "^4.0.2", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lodash": "^4.17.21", + "lucide-vue-next": "^0.552.0", "material-design-icons-iconfont": "^6.7.0", + "monaco-editor": "^0.55.1", "preline": "^2.7.0", - "reka-ui": "^2.5.1", + "quill": "^1.3.7", + "reka-ui": "^2.7.0", + "tailwind-merge": "^3.4.0", + "tailwindcss-animate": "^1.0.7", "tailwindcss-inner-border": "^0.2.0", "v-calendar": "^3.1.2", - "vue-multiselect": "^3.1.0", - "vue-search-input": "^1.1.16", - "vue3-apexcharts": "^1.7.0", + "vee-validate": "^4.15.1", + "vue-currency-input": "^3.2.1", + "vue-multiselect": "^3.4.0", + "vue-search-input": "^1.1.19", + "vue-sonner": "^2.0.9", + "vue3-apexcharts": "^1.10.0", "vuedraggable": "^4.1.0", - "vue-currency-input": "^3.2.1" + "zod": "^3.25.76" } } diff --git a/postcss.config.js b/postcss.config.js index 49c0612..f02fc56 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,6 +1,9 @@ +import tailwindcss from '@tailwindcss/postcss'; +import autoprefixer from 'autoprefixer'; + export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, + plugins: [ + tailwindcss(), + autoprefixer(), + ], }; diff --git a/resources/css/app.css b/resources/css/app.css index 013c66e..0e70702 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,10 +1,143 @@ -@import '/node_modules/floating-vue/dist/style.css'; -@import '/node_modules/vue-search-input/dist/styles.css'; -@import '/node_modules/vue-multiselect/dist/vue-multiselect.min.css'; -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; +@import "./themes.css"; +@plugin "tailwindcss-animate"; + +@custom-variant dark (&:is(.dark *)); + +@theme { + /* Disable dark mode */ + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + + /* Font Family */ + --font-family-sans: 'Figtree', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + + /* Primary brand colors */ + --color-primary-50: #eef2ff; + --color-primary-100: #e0e7ff; + --color-primary-200: #c7d2fe; + --color-primary-300: #a5b4fc; + --color-primary-400: #818cf8; + --color-primary-500: #6366f1; + --color-primary-600: #4f46e5; + --color-primary-700: #4338ca; + --color-primary-800: #3730a3; + --color-primary-900: #312e81; + --color-primary-950: #1e1b4b; + + /* Semantic colors - Success */ + --color-success-50: #f0fdf4; + --color-success-100: #dcfce7; + --color-success-200: #bbf7d0; + --color-success-300: #86efac; + --color-success-400: #4ade80; + --color-success-500: #22c55e; + --color-success-600: #16a34a; + --color-success-700: #15803d; + --color-success-800: #166534; + --color-success-900: #14532d; + + /* Semantic colors - Warning */ + --color-warning-50: #fffbeb; + --color-warning-100: #fef3c7; + --color-warning-200: #fde68a; + --color-warning-300: #fcd34d; + --color-warning-400: #fbbf24; + --color-warning-500: #f59e0b; + --color-warning-600: #d97706; + --color-warning-700: #b45309; + --color-warning-800: #92400e; + --color-warning-900: #78350f; + + /* Semantic colors - Error */ + --color-error-50: #fef2f2; + --color-error-100: #fee2e2; + --color-error-200: #fecaca; + --color-error-300: #fca5a5; + --color-error-400: #f87171; + --color-error-500: #ef4444; + --color-error-600: #dc2626; + --color-error-700: #b91c1c; + --color-error-800: #991b1b; + --color-error-900: #7f1d1d; + + /* Semantic colors - Info */ + --color-info-50: #eff6ff; + --color-info-100: #dbeafe; + --color-info-200: #bfdbfe; + --color-info-300: #93c5fd; + --color-info-400: #60a5fa; + --color-info-500: #3b82f6; + --color-info-600: #2563eb; + --color-info-700: #1d4ed8; + --color-info-800: #1e40af; + --color-info-900: #1e3a8a; + + /* Neutral grays */ + --color-neutral-50: #f9fafb; + --color-neutral-100: #f3f4f6; + --color-neutral-200: #e5e7eb; + --color-neutral-300: #d1d5db; + --color-neutral-400: #9ca3af; + --color-neutral-500: #6b7280; + --color-neutral-600: #4b5563; + --color-neutral-700: #374151; + --color-neutral-800: #1f2937; + --color-neutral-900: #111827; + + /* Spacing scale */ + --spacing-18: 4.5rem; + --spacing-88: 22rem; + --spacing-112: 28rem; + --spacing-128: 32rem; + + /* Border radius */ + --radius-4xl: 2rem; + + /* Box shadows */ + --shadow-soft: 0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04); + --shadow-medium: 0 4px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --shadow-strong: 0 10px 40px -10px rgba(0, 0, 0, 0.2); + + /* Animations */ + --animate-fade-in: fade-in 0.2s ease-in-out; + --animate-slide-up: slide-up 0.3s ease-out; + --animate-slide-down: slide-down 0.3s ease-out; + --animate-shimmer: shimmer 2s infinite linear; + + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes slide-up { + from { + transform: translateY(10px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } + + @keyframes slide-down { + from { + transform: translateY(-10px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } + + @keyframes shimmer { + from { background-position: -1000px 0; } + to { background-position: 1000px 0; } + } +} [x-cloak] { display: none; @@ -12,3 +145,127 @@ [x-cloak] { /* Ensure dropdowns/menus render above dialog overlays when appended to body */ .multiselect__content-wrapper { z-index: 2147483647 !important; } + +/* stylelint-disable-next-line at-rule-no-unknown */ +/* @theme is a valid Tailwind CSS v4 at-rule */ +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + + :root { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.488 0.243 264.376); + --primary-foreground: oklch(0.97 0.014 254.604); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.546 0.245 262.881); + --sidebar-primary-foreground: oklch(0.97 0.014 254.604); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.708 0 0); + } + + .dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.488 0.243 264.376); + --primary-foreground: oklch(0.97 0.014 254.604); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.623 0.214 259.815); + --sidebar-primary-foreground: oklch(0.97 0.014 254.604); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.439 0 0); + } + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + + button:not([disabled]), + [role="button"]:not([disabled]) { + cursor: pointer; + } +} diff --git a/resources/css/themes.css b/resources/css/themes.css new file mode 100644 index 0000000..acec8d9 --- /dev/null +++ b/resources/css/themes.css @@ -0,0 +1,396 @@ +.theme-default .theme-container, +.theme-default [data-reka-popper-content-wrapper] { + --chart-1: var(--color-blue-300); + --chart-2: var(--color-blue-500); + --chart-3: var(--color-blue-600); + --chart-4: var(--color-blue-700); + --chart-5: var(--color-blue-800); +} + +.theme-mono .theme-container, +.theme-mono [data-reka-popper-content-wrapper] { + --font-sans: var(--font-mono); + --primary: var(--color-stone-600); + --primary-foreground: var(--color-stone-50); + --chart-1: var(--color-stone-300); + --chart-2: var(--color-stone-500); + --chart-3: var(--color-stone-600); + --chart-4: var(--color-stone-700); + --chart-5: var(--color-stone-800); + --sidebar-primary: var(--color-stone-600); + --sidebar-primary-foreground: var(--color-stone-50); + --sidebar-ring: var(--color-stone-400); + + @variant dark { + --primary: var(--color-stone-500); + --primary-foreground: var(--color-stone-50); + --sidebar-primary: var(--color-stone-500); + --sidebar-primary-foreground: var(--color-stone-50); + --sidebar-ring: var(--color-stone-900); + } + + @media (min-width: 1024px) { + --font-sans: var(--font-mono); + --radius: 0.45em; + --text-lg: 1rem; + --text-xl: 1.1rem; + --text-2xl: 1.2rem; + --text-3xl: 1.3rem; + --text-4xl: 1.4rem; + --text-5xl: 1.5rem; + --text-6xl: 1.6rem; + --text-7xl: 1.7rem; + --text-8xl: 1.8rem; + --text-base: 0.85rem; + --text-sm: 0.8rem; + --spacing: 0.222222rem; + } + + .rounded-xs, + .rounded-sm, + .rounded-md, + .rounded-lg, + .rounded-xl { + border-radius: 0; + } + + .shadow-xs, + .shadow-sm, + .shadow-md, + .shadow-lg, + .shadow-xl { + box-shadow: none; + } + + [data-slot="toggle-group"], + [data-slot="toggle-group-item"], + [data-slot="checkbox"], + [data-slot="radio"], + [data-slot="switch"], + [data-slot="switch-thumb"] { + @apply !rounded-none !shadow-none; + } +} + +.theme-scaled .theme-container, +.theme-scaled [data-reka-popper-content-wrapper] { + --chart-1: var(--color-blue-300); + --chart-2: var(--color-blue-500); + --chart-3: var(--color-blue-600); + --chart-4: var(--color-blue-700); + --chart-5: var(--color-blue-800); + + @media (min-width: 1024px) { + --radius: 0.45em; + --text-lg: 1rem; + --text-xl: 1.1rem; + --text-2xl: 1.2rem; + --text-3xl: 1.3rem; + --text-4xl: 1.4rem; + --text-5xl: 1.5rem; + --text-6xl: 1.6rem; + --text-7xl: 1.7rem; + --text-8xl: 1.8rem; + --text-base: 0.85rem; + --text-sm: 0.8rem; + --spacing: 0.2rem; + } + + [data-slot="select-trigger"], + [data-slot="toggle-group-item"] { + --spacing: 0.2rem; + } + + [data-slot="card"] { + border-radius: var(--radius); + padding-block: calc(var(--spacing) * 4); + gap: calc(var(--spacing) * 2); + } + + [data-slot="card"].pb-0 { + padding-bottom: 0; + } +} + +.theme-rounded-none .theme-container, +.theme-rounded-none [data-reka-popper-content-wrapper] { + --radius: 0; +} + +.theme-rounded-small .theme-container, +.theme-rounded-small [data-reka-popper-content-wrapper] { + --radius: 0.4rem; +} + +.theme-rounded-medium .theme-container, +.theme-rounded-medium [data-reka-popper-content-wrapper] { + --radius: 0.65rem; +} + +.theme-rounded-large .theme-container, +.theme-rounded-large [data-reka-popper-content-wrapper] { + --radius: 1rem; +} + +.theme-rounded-full .theme-container, +.theme-rounded-full [data-reka-popper-content-wrapper] { + --radius: 1.2rem; +} + +.theme-inter .theme-container, +.theme-inter [data-reka-popper-content-wrapper] { + --font-sans: var(--font-inter); +} + +.theme-noto-sans .theme-container, +.theme-noto-sans [data-reka-popper-content-wrapper] { + --font-sans: var(--font-noto-sans); +} + +.theme-nunito-sans .theme-container, +.theme-nunito-sans [data-reka-popper-content-wrapper] { + --font-sans: var(--font-nunito-sans); +} + +.theme-figtree .theme-container, +.theme-figtree [data-reka-popper-content-wrapper] { + --font-sans: var(--font-figtree); +} + +.theme-blue .theme-container, +.theme-blue [data-reka-popper-content-wrapper] { + --primary: var(--color-blue-700); + --primary-foreground: var(--color-blue-50); + /* --ring: var(--color-blue-100); */ + --sidebar-primary: var(--color-blue-600); + --sidebar-primary-foreground: var(--color-blue-50); + /* --sidebar-ring: var(--color-blue-400); */ + --chart-1: var(--color-blue-300); + --chart-2: var(--color-blue-500); + --chart-3: var(--color-blue-600); + --chart-4: var(--color-blue-700); + --chart-5: var(--color-blue-800); + + @variant dark { + --primary: var(--color-blue-700); + --primary-foreground: var(--color-blue-50); + /* --ring: var(--color-blue-900); */ + --sidebar-primary: var(--color-blue-500); + --sidebar-primary-foreground: var(--color-blue-50); + /* --sidebar-ring: var(--color-blue-900); */ + } +} + +.theme-green .theme-container, +.theme-green [data-reka-popper-content-wrapper] { + --primary: var(--color-lime-600); + --primary-foreground: var(--color-lime-50); + --ring: var(--color-lime-400); + --chart-1: var(--color-green-300); + --chart-2: var(--color-green-500); + --chart-3: var(--color-green-600); + --chart-4: var(--color-green-700); + --chart-5: var(--color-green-800); + --sidebar-primary: var(--color-lime-600); + --sidebar-primary-foreground: var(--color-lime-50); + --sidebar-ring: var(--color-lime-400); + + @variant dark { + --primary: var(--color-lime-600); + --primary-foreground: var(--color-lime-50); + --ring: var(--color-lime-900); + --sidebar-primary: var(--color-lime-500); + --sidebar-primary-foreground: var(--color-lime-50); + --sidebar-ring: var(--color-lime-900); + } +} + +.theme-amber .theme-container, +.theme-amber [data-reka-popper-content-wrapper] { + --primary: var(--color-amber-600); + --primary-foreground: var(--color-amber-50); + --ring: var(--color-amber-400); + --chart-1: var(--color-amber-300); + --chart-2: var(--color-amber-500); + --chart-3: var(--color-amber-600); + --chart-4: var(--color-amber-700); + --chart-5: var(--color-amber-800); + --sidebar-primary: var(--color-amber-600); + --sidebar-primary-foreground: var(--color-amber-50); + --sidebar-ring: var(--color-amber-400); + + @variant dark { + --primary: var(--color-amber-500); + --primary-foreground: var(--color-amber-50); + --ring: var(--color-amber-900); + --sidebar-primary: var(--color-amber-500); + --sidebar-primary-foreground: var(--color-amber-50); + --sidebar-ring: var(--color-amber-900); + } +} + +.theme-rose .theme-container, +.theme-rose [data-reka-popper-content-wrapper] { + --primary: var(--color-rose-600); + --primary-foreground: var(--color-rose-50); + --ring: var(--color-rose-400); + --chart-1: var(--color-rose-300); + --chart-2: var(--color-rose-500); + --chart-3: var(--color-rose-600); + --chart-4: var(--color-rose-700); + --chart-5: var(--color-rose-800); + --sidebar-primary: var(--color-rose-600); + --sidebar-primary-foreground: var(--color-rose-50); + --sidebar-ring: var(--color-rose-400); + + @variant dark { + --primary: var(--color-rose-500); + --primary-foreground: var(--color-rose-50); + --ring: var(--color-rose-900); + --sidebar-primary: var(--color-rose-500); + --sidebar-primary-foreground: var(--color-rose-50); + --sidebar-ring: var(--color-rose-900); + } +} + +.theme-purple .theme-container, +.theme-purple [data-reka-popper-content-wrapper] { + --primary: var(--color-purple-600); + --primary-foreground: var(--color-purple-50); + --ring: var(--color-purple-400); + --chart-1: var(--color-purple-300); + --chart-2: var(--color-purple-500); + --chart-3: var(--color-purple-600); + --chart-4: var(--color-purple-700); + --chart-5: var(--color-purple-800); + --sidebar-primary: var(--color-purple-600); + --sidebar-primary-foreground: var(--color-purple-50); + --sidebar-ring: var(--color-purple-400); + + @variant dark { + --primary: var(--color-purple-500); + --primary-foreground: var(--color-purple-50); + --ring: var(--color-purple-900); + --sidebar-primary: var(--color-purple-500); + --sidebar-primary-foreground: var(--color-purple-50); + --sidebar-ring: var(--color-purple-900); + } +} + +.theme-orange .theme-container, +.theme-orange [data-reka-popper-content-wrapper] { + --primary: var(--color-orange-600); + --primary-foreground: var(--color-orange-50); + --ring: var(--color-orange-400); + --chart-1: var(--color-orange-300); + --chart-2: var(--color-orange-500); + --chart-3: var(--color-orange-600); + --chart-4: var(--color-orange-700); + --chart-5: var(--color-orange-800); + --sidebar-primary: var(--color-orange-600); + --sidebar-primary-foreground: var(--color-orange-50); + --sidebar-ring: var(--color-orange-400); + + @variant dark { + --primary: var(--color-orange-500); + --primary-foreground: var(--color-orange-50); + --ring: var(--color-orange-900); + --sidebar-primary: var(--color-orange-500); + --sidebar-primary-foreground: var(--color-orange-50); + --sidebar-ring: var(--color-orange-900); + } +} + +.theme-teal .theme-container, +.theme-teal [data-reka-popper-content-wrapper] { + --primary: var(--color-teal-600); + --primary-foreground: var(--color-teal-50); + --chart-1: var(--color-teal-300); + --chart-2: var(--color-teal-500); + --chart-3: var(--color-teal-600); + --chart-4: var(--color-teal-700); + --chart-5: var(--color-teal-800); + --sidebar-primary: var(--color-teal-600); + --sidebar-primary-foreground: var(--color-teal-50); + --sidebar-ring: var(--color-teal-400); + + @variant dark { + --primary: var(--color-teal-500); + --primary-foreground: var(--color-teal-50); + --sidebar-primary: var(--color-teal-500); + --sidebar-primary-foreground: var(--color-teal-50); + --sidebar-ring: var(--color-teal-900); + } +} + +.theme-red .theme-container, +.theme-red [data-reka-popper-content-wrapper] { + --primary: var(--color-red-600); + --primary-foreground: var(--color-red-50); + --ring: var(--color-red-400); + --chart-1: var(--color-red-300); + --chart-2: var(--color-red-500); + --chart-3: var(--color-red-600); + --chart-4: var(--color-red-700); + --chart-5: var(--color-red-800); + --sidebar-primary: var(--color-red-600); + --sidebar-primary-foreground: var(--color-red-50); + --sidebar-ring: var(--color-red-400); + + @variant dark { + --primary: var(--color-red-500); + --primary-foreground: var(--color-red-50); + --ring: var(--color-red-900); + --sidebar-primary: var(--color-red-500); + --sidebar-primary-foreground: var(--color-red-50); + --sidebar-ring: var(--color-red-900); + } +} + +.theme-yellow .theme-container, +.theme-yellow [data-reka-popper-content-wrapper] { + --primary: var(--color-yellow-400); + --primary-foreground: var(--color-yellow-900); + --ring: var(--color-yellow-400); + --chart-1: var(--color-yellow-300); + --chart-2: var(--color-yellow-500); + --chart-3: var(--color-yellow-600); + --chart-4: var(--color-yellow-700); + --chart-5: var(--color-yellow-800); + --sidebar-primary: var(--color-yellow-600); + --sidebar-primary-foreground: var(--color-yellow-50); + --sidebar-ring: var(--color-yellow-400); + + @variant dark { + --primary: var(--color-yellow-500); + --primary-foreground: var(--color-yellow-900); + --ring: var(--color-yellow-900); + --sidebar-primary: var(--color-yellow-500); + --sidebar-primary-foreground: var(--color-yellow-50); + --sidebar-ring: var(--color-yellow-900); + } +} + +.theme-violet .theme-container, +.theme-violet [data-reka-popper-content-wrapper] { + --primary: var(--color-violet-600); + --primary-foreground: var(--color-violet-50); + --ring: var(--color-violet-400); + --chart-1: var(--color-violet-300); + --chart-2: var(--color-violet-500); + --chart-3: var(--color-violet-600); + --chart-4: var(--color-violet-700); + --chart-5: var(--color-violet-800); + --sidebar-primary: var(--color-violet-600); + --sidebar-primary-foreground: var(--color-violet-50); + --sidebar-ring: var(--color-violet-400); + + @variant dark { + --primary: var(--color-violet-500); + --primary-foreground: var(--color-violet-50); + --ring: var(--color-violet-900); + --sidebar-primary: var(--color-violet-500); + --sidebar-primary-foreground: var(--color-violet-50); + --sidebar-ring: var(--color-violet-900); + } +} diff --git a/resources/js/Components/AddressCreateForm.vue b/resources/js/Components/AddressCreateForm.vue deleted file mode 100644 index bb39718..0000000 --- a/resources/js/Components/AddressCreateForm.vue +++ /dev/null @@ -1,228 +0,0 @@ - - - diff --git a/resources/js/Components/AddressUpdateForm.vue b/resources/js/Components/AddressUpdateForm.vue deleted file mode 100644 index b26481e..0000000 --- a/resources/js/Components/AddressUpdateForm.vue +++ /dev/null @@ -1,197 +0,0 @@ - - - - - diff --git a/resources/js/Components/ApplicationMark.vue b/resources/js/Components/ApplicationMark.vue index 3933bd0..e04fdd0 100644 --- a/resources/js/Components/ApplicationMark.vue +++ b/resources/js/Components/ApplicationMark.vue @@ -1,3 +1,3 @@ diff --git a/resources/js/Components/BasicTable.vue b/resources/js/Components/BasicTable.vue index facced9..e1399bb 100644 --- a/resources/js/Components/BasicTable.vue +++ b/resources/js/Components/BasicTable.vue @@ -1,5 +1,5 @@ diff --git a/resources/js/Components/ConfirmsPassword.vue b/resources/js/Components/ConfirmsPassword.vue index 7b24493..e861e51 100644 --- a/resources/js/Components/ConfirmsPassword.vue +++ b/resources/js/Components/ConfirmsPassword.vue @@ -1,118 +1,118 @@ diff --git a/resources/js/Components/CurrencyInput.vue b/resources/js/Components/CurrencyInput.vue index 2362c82..e0caaa3 100644 --- a/resources/js/Components/CurrencyInput.vue +++ b/resources/js/Components/CurrencyInput.vue @@ -1,6 +1,8 @@ \ No newline at end of file + diff --git a/resources/js/Components/DataTable/ActionMenuItem.vue b/resources/js/Components/DataTable/ActionMenuItem.vue new file mode 100644 index 0000000..656cfb7 --- /dev/null +++ b/resources/js/Components/DataTable/ActionMenuItem.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/resources/js/Components/DataTable/ColumnFilter.vue b/resources/js/Components/DataTable/ColumnFilter.vue new file mode 100644 index 0000000..f5c2308 --- /dev/null +++ b/resources/js/Components/DataTable/ColumnFilter.vue @@ -0,0 +1,134 @@ + + + + + + diff --git a/resources/js/Components/DataTable/DataTable.vue b/resources/js/Components/DataTable/DataTable.vue new file mode 100644 index 0000000..296b457 --- /dev/null +++ b/resources/js/Components/DataTable/DataTable.vue @@ -0,0 +1,705 @@ + + + diff --git a/resources/js/Components/DataTable/DataTableClient.vue b/resources/js/Components/DataTable/DataTableClient.vue index 455950b..b7a440d 100644 --- a/resources/js/Components/DataTable/DataTableClient.vue +++ b/resources/js/Components/DataTable/DataTableClient.vue @@ -1,13 +1,18 @@ + + + diff --git a/resources/js/Components/DataTable/DataTableNew.vue b/resources/js/Components/DataTable/DataTableNew.vue new file mode 100644 index 0000000..184c463 --- /dev/null +++ b/resources/js/Components/DataTable/DataTableNew.vue @@ -0,0 +1,708 @@ + + + + diff --git a/resources/js/Components/DataTable/DataTableNew2.vue b/resources/js/Components/DataTable/DataTableNew2.vue new file mode 100644 index 0000000..74067be --- /dev/null +++ b/resources/js/Components/DataTable/DataTableNew2.vue @@ -0,0 +1,625 @@ + + + diff --git a/resources/js/Components/DataTable/DataTableOld.vue b/resources/js/Components/DataTable/DataTableOld.vue new file mode 100644 index 0000000..d18d089 --- /dev/null +++ b/resources/js/Components/DataTable/DataTableOld.vue @@ -0,0 +1,884 @@ + + + + diff --git a/resources/js/Components/DataTable/DataTablePagination.vue b/resources/js/Components/DataTable/DataTablePagination.vue new file mode 100644 index 0000000..653c77f --- /dev/null +++ b/resources/js/Components/DataTable/DataTablePagination.vue @@ -0,0 +1,98 @@ + + + diff --git a/resources/js/Components/DataTable/DataTablePaginationClient.vue b/resources/js/Components/DataTable/DataTablePaginationClient.vue new file mode 100644 index 0000000..1259b30 --- /dev/null +++ b/resources/js/Components/DataTable/DataTablePaginationClient.vue @@ -0,0 +1,205 @@ + + + diff --git a/resources/js/Components/DataTable/DataTableServer.vue b/resources/js/Components/DataTable/DataTableServer.vue index e9198ad..ba7431b 100644 --- a/resources/js/Components/DataTable/DataTableServer.vue +++ b/resources/js/Components/DataTable/DataTableServer.vue @@ -1,14 +1,16 @@ + + diff --git a/resources/js/Components/DataTable/DataTableToolbarExample.vue b/resources/js/Components/DataTable/DataTableToolbarExample.vue new file mode 100644 index 0000000..0de48b8 --- /dev/null +++ b/resources/js/Components/DataTable/DataTableToolbarExample.vue @@ -0,0 +1,86 @@ + + + diff --git a/resources/js/Components/DataTable/DataTableViewOptions.vue b/resources/js/Components/DataTable/DataTableViewOptions.vue new file mode 100644 index 0000000..4e7a8a9 --- /dev/null +++ b/resources/js/Components/DataTable/DataTableViewOptions.vue @@ -0,0 +1,50 @@ + + + diff --git a/resources/js/Components/DataTable/MIGRATION.md b/resources/js/Components/DataTable/MIGRATION.md new file mode 100644 index 0000000..22bccb0 --- /dev/null +++ b/resources/js/Components/DataTable/MIGRATION.md @@ -0,0 +1,291 @@ +# DataTable Migration Guide + +## Summary of Changes + +The DataTable component has been updated to follow **shadcn-vue** architecture patterns using **TanStack Table v8**. This provides better flexibility, more features, and follows industry-standard patterns. + +## What's New + +### ✅ Components Created/Updated + +1. **`DataTableNew2.vue`** - New main component with shadcn-vue architecture +2. **`DataTableColumnHeader.vue`** - Already good, uses lucide-vue-next icons +3. **`DataTablePagination.vue`** - Already follows shadcn-vue patterns +4. **`DataTableViewOptions.vue`** - Already follows shadcn-vue patterns +5. **`DataTableToolbar.vue`** - Already exists with advanced features +6. **`columns-example.js`** - Column definition examples +7. **`README.md`** - Comprehensive documentation +8. **`DataTableExample.vue`** - Working example page + +### ✅ Utilities Added + +- **`valueUpdater()`** in `lib/utils.js` - Helper for TanStack Table state management + +## Key Improvements + +### 1. **FlexRender Integration** +Now properly uses TanStack Table's FlexRender for column headers and cells: +```vue + +``` + +### 2. **Better Column Definitions** +Supports both simple and advanced formats: + +**Simple:** +```javascript +{ key: 'name', label: 'Name', sortable: true } +``` + +**Advanced:** +```javascript +{ + accessorKey: 'name', + header: ({ column }) => h(DataTableColumnHeader, { column, title: 'Name' }), + cell: ({ row }) => h('div', {}, row.getValue('name')), +} +``` + +### 3. **Enhanced Features** +- ✅ Row selection with checkboxes +- ✅ Column visibility toggle +- ✅ Advanced filtering +- ✅ Better loading/empty states +- ✅ Custom cell slots +- ✅ Flexible toolbar + +### 4. **Better State Management** +Uses `valueUpdater()` helper for proper Vue reactivity with TanStack Table: +```javascript +onSortingChange: (updater) => valueUpdater(updater, sorting) +``` + +## Migration Steps + +### Step 1: Update Imports + +**Before:** +```vue +import DataTable from '@/Components/DataTable/DataTable.vue'; +``` + +**After:** +```vue +import DataTable from '@/Components/DataTable/DataTableNew2.vue'; +``` + +### Step 2: Update Props + +**Before:** +```vue + +``` + +**After:** +```vue + +``` + +Main prop changes: +- `rows` → `data` +- Add `route-name` for server-side pagination + +### Step 3: Column Definitions + +Your existing simple column format still works: +```javascript +const columns = [ + { key: 'id', label: 'ID', sortable: true }, + { key: 'name', label: 'Name', sortable: true }, +]; +``` + +But you can now use advanced format for more control: +```javascript +import { h } from 'vue'; +import DataTableColumnHeader from '@/Components/DataTable/DataTableColumnHeader.vue'; + +const columns = [ + { + accessorKey: 'name', + header: ({ column }) => h(DataTableColumnHeader, { column, title: 'Name' }), + cell: ({ row }) => h('div', { class: 'font-medium' }, row.getValue('name')), + }, +]; +``` + +### Step 4: Custom Cell Rendering + +**Before:** Required editing component +**After:** Use slots! + +```vue + + + +``` + +## Backward Compatibility + +The **old DataTable components are still available**: +- `DataTable.vue` - Your current enhanced version +- `DataTableServer.vue` - Your server-side version +- `DataTableOld.vue` - Original version + +You can migrate pages gradually. Both old and new can coexist. + +## Example Migration + +### Before (Client/Index.vue) + +```vue + + + +``` + +### After (Using DataTableNew2) + +```vue + + + +``` + +## Testing Your Migration + +1. **Check the example page:** + ``` + Visit: /examples/datatable + ``` + (You'll need to add a route for this) + +2. **Test features:** + - ✅ Sorting (click column headers) + - ✅ Filtering (use search input) + - ✅ Pagination (navigate pages) + - ✅ Row selection (if enabled) + - ✅ Column visibility (View button) + +3. **Check browser console:** + - No errors + - Events firing correctly + +## Common Issues + +### Issue: "FlexRender is not defined" +**Solution:** Make sure you imported it: +```javascript +import { FlexRender } from '@tanstack/vue-table'; +``` + +### Issue: Column not sorting +**Solution:** Make sure `sortable: true` is set: +```javascript +{ key: 'name', label: 'Name', sortable: true } +``` + +### Issue: Server-side not working +**Solution:** Provide both `meta` and `route-name`: +```vue + +``` + +### Issue: Custom cells not rendering +**Solution:** Use the correct slot name format: +```vue + +``` + +## Need Help? + +1. Check `README.md` for detailed documentation +2. Look at `columns-example.js` for column patterns +3. Review `DataTableExample.vue` for working examples +4. Check TanStack Table docs: https://tanstack.com/table/v8 + +## Rollback Plan + +If you encounter issues, you can always use the old components: +```vue +import DataTable from '@/Components/DataTable/DataTable.vue'; +// or +import DataTableServer from '@/Components/DataTable/DataTableServer.vue'; +``` + +Nothing breaks your existing code! diff --git a/resources/js/Components/DataTable/README.md b/resources/js/Components/DataTable/README.md new file mode 100644 index 0000000..3234a96 --- /dev/null +++ b/resources/js/Components/DataTable/README.md @@ -0,0 +1,390 @@ +# DataTable Component - Usage Guide + +This DataTable component follows the shadcn-vue architecture and uses TanStack Table v8 for powerful table functionality. + +## Features + +- ✅ Client-side and server-side pagination +- ✅ Sorting (single column) +- ✅ Filtering/Search +- ✅ Row selection +- ✅ Column visibility toggle +- ✅ Customizable column definitions +- ✅ Loading states +- ✅ Empty states +- ✅ Flexible toolbar +- ✅ Cell-level customization via slots +- ✅ Responsive design +- ✅ Laravel Inertia integration + +## Basic Usage + +### Simple Format (Recommended for basic tables) + +```vue + + + +``` + +### Advanced Format (Full TanStack Table power) + +```vue + + + +``` + +See `columns-example.js` for comprehensive column definition examples. + +## Props + +### Data Props +- `columns` (Array, required) - Column definitions (simple or TanStack format) +- `data` (Array, default: []) - Array of data objects +- `meta` (Object, default: null) - Laravel pagination meta for server-side +- `loading` (Boolean, default: false) - Loading state + +### Server-side Props +- `routeName` (String) - Laravel route name for server-side requests +- `routeParams` (Object) - Additional route parameters +- `pageParamName` (String, default: 'page') - Custom page parameter name +- `onlyProps` (Array) - Inertia.js only props +- `preserveState` (Boolean, default: true) +- `preserveScroll` (Boolean, default: true) + +### Sorting & Filtering +- `sort` (Object, default: {key: null, direction: null}) +- `search` (String, default: '') +- `filterColumn` (String) - Column to filter on +- `filterPlaceholder` (String, default: 'Filter...') + +### Pagination +- `showPagination` (Boolean, default: true) +- `pageSize` (Number, default: 10) +- `pageSizeOptions` (Array, default: [10, 25, 50, 100]) + +### Features +- `enableRowSelection` (Boolean, default: false) +- `showToolbar` (Boolean, default: true) +- `striped` (Boolean, default: false) +- `hoverable` (Boolean, default: true) +- `rowKey` (String|Function, default: 'id') + +### Empty State +- `emptyText` (String, default: 'No results.') +- `emptyIcon` (String|Object|Array) +- `emptyDescription` (String) + +## Events + +- `@update:search` - Emitted when search changes +- `@update:sort` - Emitted when sort changes +- `@update:page` - Emitted when page changes +- `@update:pageSize` - Emitted when page size changes +- `@row:click` - Emitted when row is clicked +- `@selection:change` - Emitted when selection changes + +## Client-side Example + +```vue + + + +``` + +## Server-side Example (Laravel Inertia) + +### Controller +```php +public function index(Request $request) +{ + $query = Client::query(); + + // Search + if ($request->search) { + $query->where('name', 'like', "%{$request->search}%") + ->orWhere('email', 'like', "%{$request->search}%"); + } + + // Sort + if ($request->sort && $request->direction) { + $query->orderBy($request->sort, $request->direction); + } + + $clients = $query->paginate($request->per_page ?? 10); + + return Inertia::render('Clients/Index', [ + 'clients' => $clients, + 'filters' => $request->only(['search', 'sort', 'direction']), + ]); +} +``` + +### Vue Component +```vue + + + +``` + +## Custom Cell Rendering + +### Using Slots +```vue + +``` + +### Using Column Definitions +```javascript +import { h } from 'vue'; +import { Badge } from '@/Components/ui/badge'; + +export const columns = [ + { + accessorKey: 'status', + header: 'Status', + cell: ({ row }) => { + const status = row.getValue('status'); + return h(Badge, { + variant: status === 'active' ? 'default' : 'secondary' + }, () => status); + }, + }, +]; +``` + +## Custom Toolbar + +The new toolbar is simplified and follows shadcn-vue patterns: + +```vue + +``` + +Or completely replace the toolbar: + +```vue + +``` + +## Row Selection + +```vue + + + +``` + +## Row Click Handler + +```vue + + + +``` + +## Tips + +1. **Column Keys**: Always use consistent keys/accessorKeys across your data +2. **Server-side**: Always provide `meta` and `routeName` props together +3. **Performance**: For large datasets, use server-side pagination +4. **Styling**: Use column `class` property for custom styling +5. **Slots**: Prefer slots for complex cell rendering over h() functions + +## Migration from Old DataTable + +### Before (Old API) +```vue + +``` + +### After (New API) +```vue + +``` + +Main changes: +- `rows` → `data` +- Added `route-name` prop for server-side +- More consistent prop naming +- Better TypeScript support +- More flexible column definitions + +## Component Files + +- `DataTableNew2.vue` - Main table component +- `DataTableColumnHeader.vue` - Sortable column header +- `DataTablePagination.vue` - Pagination controls +- `DataTableViewOptions.vue` - Column visibility toggle +- `DataTableToolbar.vue` - Toolbar component +- `columns-example.js` - Column definition examples diff --git a/resources/js/Components/DataTable/StatusBadge.vue b/resources/js/Components/DataTable/StatusBadge.vue new file mode 100644 index 0000000..7893196 --- /dev/null +++ b/resources/js/Components/DataTable/StatusBadge.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/resources/js/Components/DataTable/TableActions.vue b/resources/js/Components/DataTable/TableActions.vue new file mode 100644 index 0000000..f5c0254 --- /dev/null +++ b/resources/js/Components/DataTable/TableActions.vue @@ -0,0 +1,51 @@ + + + diff --git a/resources/js/Components/DataTable/columns-example.js b/resources/js/Components/DataTable/columns-example.js new file mode 100644 index 0000000..7fe26c6 --- /dev/null +++ b/resources/js/Components/DataTable/columns-example.js @@ -0,0 +1,267 @@ +import { h } from 'vue'; +import { Badge } from '@/Components/ui/badge'; +import { Button } from '@/Components/ui/button'; +import { Checkbox } from '@/Components/ui/checkbox'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/Components/ui/dropdown-menu'; +import { MoreHorizontal, ArrowUpDown } from 'lucide-vue-next'; + +/** + * Example columns definition following shadcn-vue DataTable patterns + * + * Usage: + * import { columns } from './columns' + * + * + * This is a TypeScript-like example for JavaScript. + * The columns follow TanStack Table's ColumnDef format. + */ + +/** + * Simple format - automatically converted to ColumnDef + * Use this for basic tables + */ +export const simpleColumns = [ + { key: 'id', label: 'ID', sortable: true }, + { key: 'name', label: 'Name', sortable: true }, + { key: 'email', label: 'Email', sortable: true }, + { key: 'status', label: 'Status', sortable: false }, +]; + +/** + * Advanced format - full TanStack Table ColumnDef + * Use this for custom rendering, formatting, etc. + */ +export const advancedColumns = [ + // Selection column (added automatically if enableRowSelection prop is true) + // { + // id: 'select', + // header: ({ table }) => { + // return h(Checkbox, { + // modelValue: table.getIsAllPageRowsSelected(), + // indeterminate: table.getIsSomePageRowsSelected(), + // 'onUpdate:modelValue': (value) => table.toggleAllPageRowsSelected(!!value), + // 'aria-label': 'Select all', + // }); + // }, + // cell: ({ row }) => { + // return h(Checkbox, { + // modelValue: row.getIsSelected(), + // 'onUpdate:modelValue': (value) => row.toggleSelected(!!value), + // 'aria-label': 'Select row', + // }); + // }, + // enableSorting: false, + // enableHiding: false, + // }, + + // ID column + { + accessorKey: 'id', + header: ({ column }) => { + return h( + Button, + { + variant: 'ghost', + onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'), + }, + () => ['ID', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })] + ); + }, + cell: ({ row }) => { + return h('div', { class: 'w-20 font-medium' }, row.getValue('id')); + }, + }, + + // Name column + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => { + return h('div', { class: 'font-medium' }, row.getValue('name')); + }, + }, + + // Email column with custom rendering + { + accessorKey: 'email', + header: ({ column }) => { + return h( + Button, + { + variant: 'ghost', + onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'), + }, + () => ['Email', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })] + ); + }, + cell: ({ row }) => { + return h('div', { class: 'lowercase' }, row.getValue('email')); + }, + }, + + // Amount column with formatting + { + accessorKey: 'amount', + header: () => h('div', { class: 'text-right' }, 'Amount'), + cell: ({ row }) => { + const amount = parseFloat(row.getValue('amount')); + const formatted = new Intl.NumberFormat('sl-SI', { + style: 'currency', + currency: 'EUR', + }).format(amount); + + return h('div', { class: 'text-right font-medium' }, formatted); + }, + }, + + // Status column with badge + { + accessorKey: 'status', + header: 'Status', + cell: ({ row }) => { + const status = row.getValue('status'); + const variants = { + success: 'default', + pending: 'secondary', + failed: 'destructive', + }; + + return h( + Badge, + { + variant: variants[status] || 'outline', + }, + () => status + ); + }, + }, + + // Actions column + { + id: 'actions', + enableHiding: false, + cell: ({ row }) => { + const item = row.original; + + return h( + 'div', + { class: 'text-right' }, + h( + DropdownMenu, + {}, + { + default: () => [ + h( + DropdownMenuTrigger, + { asChild: true }, + { + default: () => + h( + Button, + { + variant: 'ghost', + class: 'h-8 w-8 p-0', + }, + { + default: () => [ + h('span', { class: 'sr-only' }, 'Open menu'), + h(MoreHorizontal, { class: 'h-4 w-4' }), + ], + } + ), + } + ), + h( + DropdownMenuContent, + { align: 'end' }, + { + default: () => [ + h(DropdownMenuLabel, {}, () => 'Actions'), + h( + DropdownMenuItem, + { + onClick: () => navigator.clipboard.writeText(item.id), + }, + () => 'Copy ID' + ), + h(DropdownMenuSeparator), + h(DropdownMenuItem, {}, () => 'View details'), + h(DropdownMenuItem, {}, () => 'Edit'), + ], + } + ), + ], + } + ) + ); + }, + }, +]; + +/** + * Payments example from shadcn-vue docs + */ +export const paymentColumns = [ + { + accessorKey: 'status', + header: 'Status', + cell: ({ row }) => { + const status = row.getValue('status'); + return h('div', { class: 'capitalize' }, status); + }, + }, + { + accessorKey: 'email', + header: ({ column }) => { + return h( + Button, + { + variant: 'ghost', + onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'), + }, + () => ['Email', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })] + ); + }, + cell: ({ row }) => h('div', { class: 'lowercase' }, row.getValue('email')), + }, + { + accessorKey: 'amount', + header: () => h('div', { class: 'text-right' }, 'Amount'), + cell: ({ row }) => { + const amount = parseFloat(row.getValue('amount')); + const formatted = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount); + return h('div', { class: 'text-right font-medium' }, formatted); + }, + }, +]; + +/** + * Example with custom cell slots + * Use template slots in your component: + * + * + * + * + */ +export const columnsWithSlots = [ + { key: 'id', label: 'ID', sortable: true }, + { key: 'name', label: 'Name', sortable: true }, + { key: 'status', label: 'Status', sortable: false }, // Will use #cell-status slot + { key: 'email', label: 'Email', sortable: true }, +]; + +export default advancedColumns; diff --git a/resources/js/Components/DatePicker.vue b/resources/js/Components/DatePicker.vue new file mode 100644 index 0000000..998130c --- /dev/null +++ b/resources/js/Components/DatePicker.vue @@ -0,0 +1,152 @@ + + + + diff --git a/resources/js/Components/DateRangePicker.vue b/resources/js/Components/DateRangePicker.vue new file mode 100644 index 0000000..aed8635 --- /dev/null +++ b/resources/js/Components/DateRangePicker.vue @@ -0,0 +1,231 @@ + + + diff --git a/resources/js/Components/DialogModal.vue b/resources/js/Components/DialogModal.vue index 4f3a8ed..455114f 100644 --- a/resources/js/Components/DialogModal.vue +++ b/resources/js/Components/DialogModal.vue @@ -1,47 +1,76 @@ diff --git a/resources/js/Components/Dialogs/ConfirmationDialog.vue b/resources/js/Components/Dialogs/ConfirmationDialog.vue new file mode 100644 index 0000000..7324f8b --- /dev/null +++ b/resources/js/Components/Dialogs/ConfirmationDialog.vue @@ -0,0 +1,111 @@ + + + + diff --git a/resources/js/Components/Dialogs/CreateDialog.vue b/resources/js/Components/Dialogs/CreateDialog.vue new file mode 100644 index 0000000..a0e84f4 --- /dev/null +++ b/resources/js/Components/Dialogs/CreateDialog.vue @@ -0,0 +1,99 @@ + + + diff --git a/resources/js/Components/Dialogs/DeleteDialog.vue b/resources/js/Components/Dialogs/DeleteDialog.vue new file mode 100644 index 0000000..1ba869b --- /dev/null +++ b/resources/js/Components/Dialogs/DeleteDialog.vue @@ -0,0 +1,96 @@ + + + + diff --git a/resources/js/Components/Dialogs/UpdateDialog.vue b/resources/js/Components/Dialogs/UpdateDialog.vue new file mode 100644 index 0000000..5f119e4 --- /dev/null +++ b/resources/js/Components/Dialogs/UpdateDialog.vue @@ -0,0 +1,99 @@ + + + diff --git a/resources/js/Components/Dialogs/WarningDialog.vue b/resources/js/Components/Dialogs/WarningDialog.vue new file mode 100644 index 0000000..8efd074 --- /dev/null +++ b/resources/js/Components/Dialogs/WarningDialog.vue @@ -0,0 +1,104 @@ + + + + diff --git a/resources/js/Components/DocumentEditDialog.vue b/resources/js/Components/DocumentEditDialog.vue deleted file mode 100644 index c1767f0..0000000 --- a/resources/js/Components/DocumentEditDialog.vue +++ /dev/null @@ -1,96 +0,0 @@ - - - \ No newline at end of file diff --git a/resources/js/Components/DocumentUploadDialog.vue b/resources/js/Components/DocumentUploadDialog.vue deleted file mode 100644 index 9d78599..0000000 --- a/resources/js/Components/DocumentUploadDialog.vue +++ /dev/null @@ -1,127 +0,0 @@ - - - \ No newline at end of file diff --git a/resources/js/Components/DocumentViewerDialog.vue b/resources/js/Components/DocumentViewerDialog.vue deleted file mode 100644 index c07671b..0000000 --- a/resources/js/Components/DocumentViewerDialog.vue +++ /dev/null @@ -1,26 +0,0 @@ - - -