diff --git a/.env.example b/.env.example index f9a30e9..2e664db 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,9 @@ REVERB_HOST=127.0.0.1 REVERB_PORT=8080 REVERB_SCHEME=http +SCOUT_DRIVER=database +SCOUT_QUEUE=true + MAIL_MAILER=log MAIL_SCHEME=null MAIL_HOST=127.0.0.1 diff --git a/app/Livewire/Common/GlobalSearch.php b/app/Livewire/Common/GlobalSearch.php new file mode 100644 index 0000000..a299d4e --- /dev/null +++ b/app/Livewire/Common/GlobalSearch.php @@ -0,0 +1,138 @@ +query) < 2) { + $this->results = []; + $this->showResults = false; + + return; + } + + $this->search(); + $this->showResults = true; + } + + public function search(): void + { + $q = $this->query; + + $projectIds = Auth::user()->projects()->pluck('id'); + + $features = Feature::search($q) + ->where('project_id', $projectIds) + ->take($this->limit) + ->get() + ->map(function ($feature) { + return [ + 'type' => 'feature', + 'type_label' => 'Elemento', + 'id' => $feature->id, + 'title' => $feature->name, + 'subtitle' => $feature->layer?->phase?->name ?? 'Sin fase', + 'url' => route('projects.map', $feature->layer?->phase?->project_id).'?feature='.$feature->id, + 'icon' => 'square-2-stack', + 'color' => 'blue', + ]; + }); + + $issues = Issue::search($q) + ->where('project_id', $projectIds) + ->take($this->limit) + ->get() + ->map(function ($issue) { + return [ + 'type' => 'issue', + 'type_label' => 'Incidencia', + 'id' => $issue->id, + 'title' => $issue->title, + 'subtitle' => $issue->project?->name ?? 'Sin proyecto', + 'url' => route('projects.issues.show', [$issue->project_id, $issue->id]), + 'icon' => 'exclamation-triangle', + 'color' => 'red', + ]; + }); + + $tasks = Task::search($q) + ->where('project_id', $projectIds) + ->take($this->limit) + ->get() + ->map(function ($task) { + return [ + 'type' => 'task', + 'type_label' => 'Tarea', + 'id' => $task->id, + 'title' => $task->title, + 'subtitle' => $task->project?->name ?? 'Sin proyecto', + 'url' => route('projects.tasks.show', [$task->project_id, $task->id]), + 'icon' => 'clipboard-document-check', + 'color' => 'green', + ]; + }); + + $inspections = Inspection::search($q) + ->where('project_id', $projectIds) + ->take($this->limit) + ->get() + ->map(function ($inspection) { + return [ + 'type' => 'inspection', + 'type_label' => 'Inspección', + 'id' => $inspection->id, + 'title' => $inspection->feature?->name ?? 'Inspección', + 'subtitle' => $inspection->template?->name ?? 'Sin template', + 'url' => route('projects.map', $inspection->project_id).'?inspection='.$inspection->id, + 'icon' => 'magnifying-glass', + 'color' => 'purple', + ]; + }); + + $this->results = $features + ->concat($issues) + ->concat($tasks) + ->concat($inspections) + ->sortBy(fn ($r) => $r['type']) + ->take($this->limit * 4) + ->values() + ->toArray(); + } + + #[On('close-search-results')] + public function hideResults(): void + { + $this->showResults = false; + } + + public function clearSearch(): void + { + $this->query = ''; + $this->results = []; + $this->showResults = false; + } + + public function render() + { + return view('livewire.common.global-search'); + } +} \ No newline at end of file diff --git a/app/Models/Feature.php b/app/Models/Feature.php index 25edad5..675fc7a 100644 --- a/app/Models/Feature.php +++ b/app/Models/Feature.php @@ -7,10 +7,11 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Laravel\Scout\Searchable; class Feature extends Model { - use LogsActivity, SoftDeletes; + use LogsActivity, SoftDeletes, Searchable; const STATUSES = ['planned', 'started', 'in_progress', 'completed', 'verified']; @@ -156,4 +157,23 @@ class Feature extends Model return $spi >= 0.95; } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'project_id' => $this->layer->phase->project_id ?? null, + 'phase_id' => $this->layer->phase_id ?? null, + 'layer_id' => $this->layer_id ?? null, + 'status' => $this->status, + 'progress' => $this->progress, + ]; + } + + public function getScoutKey(): string + { + return (string) $this->id; + } } diff --git a/app/Models/Inspection.php b/app/Models/Inspection.php index e230126..cda63c0 100644 --- a/app/Models/Inspection.php +++ b/app/Models/Inspection.php @@ -5,10 +5,11 @@ namespace App\Models; use App\Traits\LogsActivity; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Laravel\Scout\Searchable; class Inspection extends Model { - use LogsActivity, SoftDeletes; + use LogsActivity, SoftDeletes, Searchable; protected static function booted(): void { @@ -87,4 +88,25 @@ class Inspection extends Model { return $q->where('status', 'rejected'); } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'project_id' => $this->project_id, + 'layer_id' => $this->layer_id, + 'feature_id' => $this->feature_id, + 'template_id' => $this->template_id, + 'user_id' => $this->user_id, + 'inspector_user_id' => $this->inspector_user_id, + 'status' => $this->status, + 'result' => $this->result, + 'notes' => $this->notes, + ]; + } + + public function getScoutKey(): string + { + return (string) $this->id; + } } diff --git a/app/Models/Issue.php b/app/Models/Issue.php index 825f7c6..88a6027 100644 --- a/app/Models/Issue.php +++ b/app/Models/Issue.php @@ -5,10 +5,11 @@ namespace App\Models; use App\Traits\LogsActivity; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Laravel\Scout\Searchable; class Issue extends Model { - use LogsActivity, SoftDeletes; + use LogsActivity, SoftDeletes, Searchable; const STATUSES = ['open', 'in_review', 'resolved', 'closed']; @@ -141,4 +142,26 @@ class Issue extends Model default => '#6b7280', }; } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'description' => $this->description, + 'project_id' => $this->project_id, + 'feature_id' => $this->feature_id, + 'inspection_id' => $this->inspection_id, + 'status' => $this->status, + 'priority' => $this->priority, + 'type' => $this->type, + 'reported_by' => $this->reported_by, + 'assigned_to' => $this->assigned_to, + ]; + } + + public function getScoutKey(): string + { + return (string) $this->id; + } } diff --git a/app/Models/Task.php b/app/Models/Task.php index 81ecf9e..502318b 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -11,10 +11,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Laravel\Scout\Searchable; class Task extends Model { - use HasFactory, LogsActivity, SoftDeletes; + use HasFactory, LogsActivity, SoftDeletes, Searchable; protected $fillable = [ 'project_id', @@ -328,4 +329,27 @@ class Task extends Model 'critical' => 'Crítica', ]; } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'description' => $this->description, + 'project_id' => $this->project_id, + 'phase_id' => $this->phase_id, + 'parent_task_id' => $this->parent_task_id, + 'status' => $this->status, + 'priority' => $this->priority, + 'assigned_to' => $this->assigned_to, + 'due_date' => $this->due_date?->toDateString(), + 'start_date' => $this->start_date?->toDateString(), + 'completed_at' => $this->completed_at?->toDateTimeString(), + ]; + } + + public function getScoutKey(): string + { + return (string) $this->id; + } } diff --git a/composer.json b/composer.json index 2e0be88..fd45754 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "laravel/framework": "^12.0", "laravel/reverb": "^1.11", "laravel/sanctum": "^4.3", + "laravel/scout": "^11.6", "laravel/tinker": "^2.10.1", "league/geotools": "^1.3", "livewire/livewire": "^3.6.4", diff --git a/composer.lock b/composer.lock index bb58305..bc553cb 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": "55192809cb3c94d88a379dcab1097f21", + "content-hash": "5a75bade70107149539891d074c84a53", "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -2069,6 +2069,87 @@ }, "time": "2026-04-30T11:46:25+00:00" }, + { + "name": "laravel/scout", + "version": "v11.6.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/scout.git", + "reference": "860f8246d5712fe6e18907119def44d2633b4622" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/scout/zipball/860f8246d5712fe6e18907119def44d2633b4622", + "reference": "860f8246d5712fe6e18907119def44d2633b4622", + "shasum": "" + }, + "require": { + "illuminate/bus": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/pagination": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "conflict": { + "algolia/algoliasearch-client-php": "<3.2.0|>=5.0.0" + }, + "require-dev": { + "algolia/algoliasearch-client-php": "^3.2|^4.0", + "meilisearch/meilisearch-php": "^1.0", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.31|^8.36|^9.15|^10.8|^11.0", + "php-http/guzzle7-adapter": "^1.0", + "phpstan/phpstan": "^1.10", + "typesense/typesense-php": "^4.9.3" + }, + "suggest": { + "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).", + "laravel/ai": "Required to generate embeddings for semantic and hybrid search.", + "meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0).", + "typesense/typesense-php": "Required to use the Typesense engine (^4.9)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Scout\\ScoutServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Scout\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Scout provides a driver based solution to searching your Eloquent models.", + "keywords": [ + "algolia", + "laravel", + "search" + ], + "support": { + "issues": "https://github.com/laravel/scout/issues", + "source": "https://github.com/laravel/scout" + }, + "time": "2026-08-25T20:32:21+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.12", diff --git a/config/scout.php b/config/scout.php new file mode 100644 index 0000000..8c0b9ae --- /dev/null +++ b/config/scout.php @@ -0,0 +1,261 @@ + env('SCOUT_DRIVER', 'collection'), + + /* + |-------------------------------------------------------------------------- + | Index Prefix + |-------------------------------------------------------------------------- + | + | Here you may specify a prefix that will be applied to all search index + | names used by Scout. This prefix may be useful if you have multiple + | "tenants" or applications sharing the same search infrastructure. + | + */ + + 'prefix' => env('SCOUT_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Queue Data Syncing + |-------------------------------------------------------------------------- + | + | This option allows you to control if the operations that sync your data + | with your search engines are queued. When this is set to "true" then + | all automatic data syncing will get queued for better performance. + | + */ + + 'queue' => env('SCOUT_QUEUE', true), + + /* + |-------------------------------------------------------------------------- + | Database Transactions + |-------------------------------------------------------------------------- + | + | This configuration option determines if your data will only be synced + | with your search indexes after every open database transaction has + | been committed, thus preventing any discarded data from syncing. + | + */ + + 'after_commit' => false, + + /* + |-------------------------------------------------------------------------- + | Chunk Sizes + |-------------------------------------------------------------------------- + | + | These options allow you to control the maximum chunk size when you are + | mass importing data into the search engine. This allows you to fine + | tune each of these chunk sizes based on the power of the servers. + | + */ + + 'chunk' => [ + 'searchable' => 500, + 'unsearchable' => 500, + ], + + /* + |-------------------------------------------------------------------------- + | Soft Deletes + |-------------------------------------------------------------------------- + | + | This option allows to control whether to keep soft deleted records in + | the search indexes. Maintaining soft deleted records can be useful + | if your application still needs to search for the records later. + | + */ + + 'soft_delete' => false, + + /* + |-------------------------------------------------------------------------- + | Identify User + |-------------------------------------------------------------------------- + | + | This option allows you to control whether to notify the search engine + | of the user performing the search. This is sometimes useful if the + | engine supports any analytics based on this application's users. + | + | Supported engines: "algolia" + | + */ + + 'identify' => env('SCOUT_IDENTIFY', false), + + /* + |-------------------------------------------------------------------------- + | Algolia Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Algolia settings. Algolia is a cloud hosted + | search engine which works great with Scout out of the box. Just plug + | in your application ID and admin API key to get started searching. + | + */ + + 'algolia' => [ + 'id' => env('ALGOLIA_APP_ID', ''), + 'secret' => env('ALGOLIA_SECRET', ''), + 'index-settings' => [ + // 'users' => [ + // 'searchableAttributes' => ['id', 'name', 'email'], + // 'attributesForFaceting'=> ['filterOnly(email)'], + // ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Meilisearch Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Meilisearch settings. Meilisearch is an open + | source search engine with minimal configuration. Below, you can state + | the host and key information for your own Meilisearch installation. + | + | See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options + | + */ + + 'meilisearch' => [ + 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), + 'key' => env('MEILISEARCH_KEY'), + 'index-settings' => [ + // 'users' => [ + // 'filterableAttributes' => ['id', 'name', 'email'], + // 'embedders' => [ + // 'default' => [ + // 'source' => 'userProvided', + // 'dimensions' => 1536, + // ], + // ], + // ], + ], + 'model-settings' => [ + // User::class => [ + // 'embedding' => [ + // 'embedder' => 'default', + // 'dimensions' => 1536, + // ], + // ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Typesense Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Typesense settings. Typesense is an open + | source search engine using minimal configuration. Below, you will + | state the host, key, and schema configuration for the instance. + | + */ + + 'typesense' => [ + 'client-settings' => [ + 'api_key' => env('TYPESENSE_API_KEY', 'xyz'), + 'nodes' => [ + [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + ], + 'nearest_node' => [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + 'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2), + 'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30), + 'num_retries' => env('TYPESENSE_NUM_RETRIES', 3), + 'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1), + ], + // 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000), + 'model-settings' => [ + // User::class => [ + // 'collection-schema' => [ + // 'fields' => [ + // [ + // 'name' => 'id', + // 'type' => 'string', + // ], + // [ + // 'name' => 'name', + // 'type' => 'string', + // ], + // [ + // 'name' => 'created_at', + // 'type' => 'int64', + // ], + // ], + // 'default_sorting_field' => 'created_at', + // ], + // 'search-parameters' => [ + // 'query_by' => 'name' + // ], + // ], + ], + 'import_action' => env('TYPESENSE_IMPORT_ACTION', 'upsert'), + ], + + /* + |-------------------------------------------------------------------------- + | Turbopuffer Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Turbopuffer connection and the schema and + | searchable attributes defined by each of your application's models. + | Turbopuffer is a scalable engine with full-text + vector search. + | + */ + + 'turbopuffer' => [ + 'api_key' => env('TURBOPUFFER_API_KEY'), + 'region' => env('TURBOPUFFER_REGION', 'gcp-us-central1'), + 'base_url' => env('TURBOPUFFER_BASE_URL'), + 'timeout' => env('TURBOPUFFER_TIMEOUT', 60), + 'connect_timeout' => env('TURBOPUFFER_CONNECT_TIMEOUT', 5), + 'retries' => env('TURBOPUFFER_RETRIES', 3), + 'model-settings' => [ + // User::class => [ + // 'searchable-attributes' => [ + // 'name' => 2, + // 'email' => 1, + // ], + // 'embedding' => [ + // 'attribute' => 'embedding', + // 'dimensions' => 1536, + // ], + // 'schema' => [ + // 'name' => ['type' => 'string', 'full_text_search' => true], + // 'email' => ['type' => 'string', 'full_text_search' => true], + // 'embedding' => ['type' => '[1536]f32', 'ann' => true], + // ], + // ], + ], + ], + +]; diff --git a/resources/views/livewire/common/global-search.blade.php b/resources/views/livewire/common/global-search.blade.php new file mode 100644 index 0000000..baf61b3 --- /dev/null +++ b/resources/views/livewire/common/global-search.blade.php @@ -0,0 +1,108 @@ +
+
+ +
+ +
+
+ + + +
+ + @if($query) + + @endif +
+
+ + + @if($showResults && count($results) > 0) + + @elseif($showResults && strlen($query) >= 2) +
+
+ +

{{ __('No se encontraron resultados para') }} “{{ $query }}”

+
+
+ @endif +
+ + @push('scripts') + + @endpush +
\ No newline at end of file