Updating styling and looks of the installer/admin/system filament pages (#2211)
* fix(installer): handle missing DB and fix CSRF middleware class - InstalledCheck: wrap Schema::hasTable in try-catch to handle when database is unreachable or does not exist, preventing crash before installer can redirect - SystemPanelProvider, AdminPanelProvider: fix non-existent PreventRequestForgery class to ValidateCsrfToken (Laravel 12) - .env.example: change default SESSION_DRIVER from database to file so fresh installs don't require sessions table before migrations run * Filament installer/system styles and changes * Don't include frontend js in system/admin * Don't include frontend js in system/admin * fix(translations): normalize 'informations' and snake_case keys - Fix ungrammatical plural 'informations' → 'Information' across all locales - Rename joined-word translation keys to snake_case convention: auth: create_account, forgot_password, full_name, email_address, etc. pireps: new_flight_report, flight_time, flight_level, fields_readonly, etc. common: newest_pilots, live_map, rights_reserved, toggle_colors flights: flight_time, profile: transfer_hours, widgets: live_map dashboard: total_hours, your_balance, your_last_report, no_reports_yet - Update all PHP/Blade references to renamed keys - Add missing translation keys to non-English locales (send_reset_link, comment, error_changing_state, fields, field_value, landing_rate, etc.) - Fix fr/filament.php: two keys on single line - Fix en/aircraft.php: double space in 'Dry Weight' - Run Pint on all modified files * Design and other small changes * feat(admin): add News Filament resource and seed PIREP sample data Replace the dashboard News widget with a full Filament resource that opens Create/Edit in modals (no separate pages routed). Adds the matching policy so filament-shield can wire permissions. Add a dev seed script (scripts/seed_sample_pireps.php) that inserts sample airports, subfleets, aircraft, and 10 admin-owned PIREPs (last two PENDING) to populate an empty database for UI testing. Fix lazy-load exception on the PIREPs admin table by eager-loading airline, aircraft and user via modifyQueryUsing; the table only worked before because the database had no PIREPs. * chore(admin): pending Filament panel refactor in working tree Pre-existing uncommitted work from earlier sessions. Not authored or reviewed in this commit's session; batched here only to clear the working tree. Includes: - Airports resource simplified to modal-via-no-route pattern (CreateAirport, EditAirport page stubs deleted; getPages trimmed to index only) - Modules page renamed to Addons - New plugins: ClearCachesPlugin, SidebarCollapseTogglePlugin - ClearCaches Livewire component - Filament admin theme.css restyle - NavigationGroup enum + lang/common, lang/filament tweaks - system/brand.blade.php moved to shared/brand.blade.php - Override attribute normalization across Filament resources * fix(review): address CodeRabbit PR #2211 review feedback Critical - Restore CSRF middleware (PreventRequestForgery) in Admin + System panels - Fix Addons page redirectRoute (modules -> addons) in 3 actions - SeederService: fix SplFileInfo TypeError, guard missing seed dir, arrow fn - YamlSeeder: propagate $ignore_errors, fix SplFileInfo bug, guard file read, proper File facade import - Installer: gate install via withoutGlobalScopes()->exists() not count(), drop duplicated stream() of full buffer after chunked streaming - .env.example: APP_DEBUG/DEBUGBAR_ENABLED default to false Bugs - seed_sample_pireps.php: swap KSAN/KLAX lat/lon - theme.css: drop phantom mobile ::before rule (no matching selector) - module-links-topbar.blade: localize hardcoded Admin label - seven/app.blade: drop @vite call referencing removed bundles i18n - de/auth: fix Adresse typo + translate send_reset_link - de/installer.title: brand wordmark phpvms - fr/auth.toc_accept: fix acceptez typo - es-es/it/tr/jp/pt-br: translate 10 PIREP keys + send_reset_link/fill_captcha - de/es-es/it/jp/tr/common: add toggle_colors Cleanup - Remove dead commented blocks (Installer headerActions, InstallSeeder, YamlSeeder, seed_sample_pireps section dividers) - NewsResource label uses common.news namespace - login-hero alt uses lowercase brand Rector + Pint auto-fixes - Add declare(strict_types=1) where missing - Add arrow-fn return types for brandLogo + News table schema - Standardize catch-block exception names * wip: pre-plan baseline (pirep map work + admin/frontend JS split) * feat(pireps): add PerformanceChartService for ACARS chart datasets * feat(pireps): load fares/field_values + build performance chart payload * refactor(pireps): drop relation manager tabs (will embed inline) * feat(pireps): register Chart.js wrapper for performance charts * feat(pireps): add detail page header + stat strip partial * feat(pireps): add unified Route & Performance card partial * feat(pireps): add notes + filed route partial * feat(pireps): add detail sidebar partial * feat(pireps): wire new detail blade composition + embedded RMs * feat(pireps): style v2 detail page (Stripe-flavored, theme tokens, dark mode) * chore(pireps): drop legacy .fi-pirep-modal-* styles * fix(pireps): inline Acars query to satisfy PHPStan level 5 larastan does not forward #[Scope] attribute methods through HasMany relation builders, so calling `->ofType()->orderedByCreatedAt()` on the relation tripped method.notFound at PHPStan level 5. Replace with explicit where() + orderBy() — same runtime behavior, no scope-forwarding surface for the analyzer to miss. * fix(pireps): prune wasted eager loads + preserve zero-fuel samples - Eager-load `user.rank` (sidebar reads $pilot->rank->name and would N+1 on every detail page view). - Drop `comments.user`, `transactions`, `fares.fare` from loadMissing — the embedded relation managers issue their own queries, so preloading at the page level only adds round-trips for data the page itself never uses. - Fix fuel series to treat a fuel value of `0` as a real sample rather than null. Previously `$s->fuel ? ... : null` collapsed legitimate empty-tanks readings into gaps. * fix(pireps): restore eager loads for embedded relation managers Filament relation managers ARE separate Livewire queries, but their blade columns still reach into nested relations on each row (e.g. PirepFare->fare in FaresRelationManager). With lazy loading disabled globally via Model::preventLazyLoading, those accesses hard-fail with LazyLoadingViolationException. Restore comments.user / transactions / fares.fare to the parent loadMissing(). Keep user.rank from the earlier fix. Reverts the wasted-load optimization in d6a8a3b5 — the optimization was based on speculation, not measurement, and broke real runtime behavior. Net effect: one extra preload per detail page, versus a 500 error. Refs: detail.blade.php:46 @livewire(FaresRelationManager) * fix(pireps): address review findings (eager loads, ts null guard, downsample tail) Tier A fixes from post-merge review of the redesign branch. ViewPirep::mount - Add fares.pirep + field_values.pirep to loadMissing. The FaresRelationManager and FieldValuesRelationManager column `disabled` closures call `$record->pirep->read_only` per row. Under Model::preventLazyLoading (active in dev/test) that triggered a lazy-load violation when editing any fare/field value. Production silently lazy-loaded on every edit. - Drop airline from loadMissing. No partial or relation manager touches the airline relation; the preload was dead weight. PerformanceChartService::ts - Guard against null Acars$created_at (documented Carbon|null; partial imports leave it unset). Use \DateTimeInterface instanceof check rather than ?->, because larastan narrows the model property to non-null Carbon and reports nullsafe.neverNull at level 5 even when the runtime type is genuinely null. PerformanceChartService::downsample - Always include the final sample. Previously `$i % $step === 0` could drop the last point when total count was not a multiple of step, hiding the touchdown / arrival sample from the chart. Index 0 stays included via the existing modulo, so first + last are both preserved. * refactor(pireps): drop route text card, restore route bar under map Removes the raw waypoint route card from the PIREP view (notes partial), restores the route bar (departure/arrival ICAO + name + block times + line) that sits below the map in route-performance partial, and re-adds its matching CSS in the admin theme. - resources/views/filament/pireps/detail/notes.blade.php: remove route text card - resources/views/filament/pireps/detail/route-performance.blade.php: restore route bar block + php helpers (blockOff/blockOn/duration/unitDistance) - resources/css/filament/admin/theme.css: restore .fi-pirep-detail-v2-route-bar styles * wip(pireps): landing analysis card, perf chart split, comment policy + tooling Bundles in-progress PIREP detail work plus build tooling that landed in the working tree: PIREP detail - Landing analysis card: runway plan-views, scorecard polar, attitude indicators (route-performance partial owns the markup; see prior commit). - PerformanceChartService: build landing payload + extra chart series. - Sidebar + modal/detail partials: stacked custom-fields card, layout tweaks. - PirepCommentPolicy + RelationManager wiring for per-row authorization. Assets - Split AlpineComponents into resources/js/components/{pirep-landing-analysis,pirep-performance-chart}.js. - bin/build.js esbuild driver; package.json/vite.config.js/.gitignore updates. - Drop legacy resources/js/admin/pirep-performance-chart.js (moved to components/). Misc - AdminPanelProvider, base_map.js, config/services.php small adjustments. - justfile task runner. - composer/package lock updates. * feat(admin): improve PIREP detail page layout and theming - Split fares/transactions into separate cards (was single Finance card) - Fares: plain text columns, no pagination, currency formatting - Transactions: JetBrains Mono money amounts, no pagination - Net total row in its own card - Sidebar facts (Pilot/Flight) use stacked layout matching PIREP fields - JetBrains Mono font for all money values (fi-pirep-money-mono) - Map switches CartoDB.Voyager/DarkMatter on theme change - Admin sidebar width 14.5rem - PIREP detail sidebar sticky positioning accounts for topbar * View pirep page updates * fix: admin pirep page UX improvements and chart stability - View PIREP button: transparent background, no fill - Flight number links to view PIREP page - PIREP detail heading shows flight number + route (VMS7620 C.PF MMMX→MMAA) - Hero section: pilot name + aircraft on top line, filed time + source on secondary - Sidebar: remove sticky scroll, let entire page scroll naturally - Performance chart: fix canvas detachment on tab switch by wrapping in wire:ignore parent and using .canvas * Shape up finances table * feat: add flight log timeline tab with phase-colored badges Two-column flight strip on Flight Log tab rendering ACARS LOG entries (type=2) with timestamp/altitude/speed badges and phase assignment from PerformanceChartService phase detection. Sort toggle flips between earliest-first and latest-first order. * fix(review): address code review blockers - phpunit.xml: restore DB_CONNECTION=sqlite (suite was hitting MySQL with :memory: database name, all tests 500ing on first query) - ViewPirep::getHeading: use typed $this->record over getRecord() to fix 4 phpstan property.notFound errors at level 5 - NewsResource: drop user_id overwrite on EditAction; preserved the original author. user_id only set on create now. - PerformanceChartService: remove discarded toFloat call on landing speed (no consumer anywhere in codebase) Pre-PR checks: pint v, phpstan v, rector v, pest v (368 passed). * chore(review): tidy review minors - finance widgets: refresh /livewire/update comment to v4 hashed path format; widget visibility logic is path-agnostic so behavior unchanged - seed_sample_pireps: make bulk PIREP loop idempotent. lookup by source_name + flight_number + airport pair so re-running the script no longer duplicates the 10 routes on each invocation * fix(pireps): flight-log rows respect dark mode Inline style="background: #..." on each row + badge was overriding the dark-mode CSS rules, leaving alternating rows white in dark mode. - blade: drop the phaseColor/phaseBg/phaseTextColor/rowBg PHP closures. collapse to a single $phaseBucket helper that maps a phase code to a bucket name (ground/climb/cruise/descent/land/neutral). row + badge pick up CSS modifier classes instead of inline styles. - theme.css: add zebra + phase tint rules for the row (.--zebra, .--phase-climb, .--phase-land) plus six badge variants. each has a light + dark pair using translucent tints with brighter text so the badge stays readable against the gray-900 row background. * fix(ci): guard Vite::asset() against missing build manifest composer install runs `package:discover` which boots service providers. AdminPanelProvider::register() called Vite::asset() eagerly, throwing ViteManifestNotFoundException on CI / fresh clones where composer runs before npm has built the frontend. Move the admin maps Js asset behind an `is_file(public_path(...))` guard so the provider registers the rest of the assets unconditionally and skips the Vite-resolved entry until the manifest exists. The map blade has its own @vite() at render time so end-user behavior is unchanged once `npm run build` has run. Verified locally by removing public/build/manifest.json and running composer dump-autoload — package:discover now completes cleanly. * fix(ci): guard AlpineComponent assets against missing dist files filament:upgrade (post-autoload-dump) was throwing on CI when copying resources/js/dist/components/pirep-performance-chart.js to the public assets folder. The esbuild'd Alpine components are produced by bin/build.js and not committed, so a fresh clone has no dist directory when composer post-autoload hooks run. Loop the components through an `is_file()` guard like the Vite manifest check added in the previous commit. Provider registration completes when the frontend hasn't been built; the components register the moment `npm run build` (or `node bin/build.js`) creates the files. Verified by removing resources/js/dist and re-running `php artisan filament:upgrade` — clean. * chore(ci): build frontend before composer + commit dist artifacts Two coordinated fixes for CI ordering + asset shipping: (A) Reorder CI so Node + frontend build run BEFORE composer install. The Laravel post-autoload-dump hook chain (package:discover -> filament:upgrade) boots service providers which read the Vite manifest and copy AlpineComponent files at register time. Running composer first threw ViteManifestNotFoundException and copy() ENOENT on a fresh clone. Affects all three jobs in build.yml: - `build` (matrix x3 PHP versions): Node setup moved above composer - `artifacts` (release packager): same swap - `docker` (image builder): Node setup added (was composer-only) (C) Commit `resources/js/dist/components/*.js` esbuild output. Removes the runtime dependency on `node bin/build.js` for downstream consumers (shared hosting installs, fresh composer-create-project flows). Matches the existing convention for `public/js/filament/*` and `public/assets/frontend/js/*` which are already committed pre-built. A "Verify committed dist files match a fresh build" CI step diffs the post-build tree against HEAD so contributors who edit the AlpineComponent sources without re-running `npm run build:components` fail the build with a clear error rather than shipping stale dist files. The defensive `is_file()` guards in AdminPanelProvider stay (committed in 7fdf2b2b + 97618bb8) — belt and suspenders against partial checkouts. Refs PR #2211 review. * chore(js): reorganize Filament components under admin/ + lint cleanup CI lint failures surfaced two issues that local lint had buried under 1318 warnings from the committed esbuild output (Chart.js internals): - resources/js/app.js was an empty stub (`//`), never imported anywhere. Removed. - RW_RUNWAY_TOP in pirep-landing-analysis.js was declared but never used. Removed. Folded in a directory reorg while touching the same files: - Sources move to resources/js/admin/components/. Mirrors the existing resources/js/admin/ tree (request.js, storage.js, maps/, etc.) which was created in this branch for the admin-only JS split. - Esbuild output mirrors the source layout — moves to resources/js/dist/admin/components/. - bin/build.js entry + outfile paths updated. - AdminPanelProvider resource_path() updated to match. Tooling guards added so dist noise stops drowning real warnings: - .oxlintrc.json: ignore resources/js/dist/** (bundled output) - .oxfmtrc.json: ignore resources/js/dist/** (minified output) - bin/build.js itself was new in this branch and not yet oxfmt-clean; reformatted (quotes/semis/indent). Verified locally: oxlint 0 warnings 0 errors, fmt:check clean for all tracked files, pint v, phpstan v, rector v. * fix(ci): three-step bootstrap to break composer<->vite chicken-and-egg The previous "npm before composer" reorder broke the build: resources/css/filament/admin/theme.css imports vendor/filament/filament/resources/css/theme.css, which doesn't exist until composer install has run. Vite resolve failed at CI line 1. Reverting to "composer before npm" re-triggers the original failure (ViteManifestNotFoundException during package:discover; ENOENT during filament:upgrade) because composer post-autoload-dump boots service providers that need the Vite manifest + dist files. Fix: three-step bootstrap, applied to all three jobs in build.yml. 1. composer install --no-scripts Pulls vendor/ (Vite needs it for theme.css @imports) WITHOUT firing post-autoload-dump (which would boot the providers prematurely). 2. npm install + npm run build Now resolves the vendor CSS @import. Produces public/build/manifest.json and rebuilds resources/js/dist/admin/components/*.js. 3. composer dump-autoload Re-fires post-autoload-dump. package:discover and filament:upgrade boot providers cleanly — the Vite manifest and dist files are both present. Verified locally inside the dev container: removed vendor/, public/build/, resources/js/dist/, ran the three steps, all clean. The freshness check (git diff --exit-code resources/js/dist) and the build matrix's lint/fmt/pint/pest/phpstan stages run unchanged — only the bootstrap order in front of them changes. * fix(installer): address CodeRabbit review feedback - Add Response return type to InstalledCheck::handle() - Translate hero_title for es-es, fr, pt-br locales - Standardize brand alt text to 'phpvms' - Null-safe source label in PIREP row blade to avoid error when source_name is set but source enum is null - Render landing_rate stat box for 0 values (use !== null check) * chore(build): stop committing compiled JS/CSS assets Untrack frontend build artifacts and rely on CI to (re)build them: - resources/js/dist/ AlpineComponents (output of bin/build.js) - public/js/filament/, public/css/filament/, public/fonts/filament/ - public/css/filament-spatie-backup/ The Filament assets are re-published by 'php artisan filament:upgrade' during composer post-autoload-dump. The AlpineComponents are produced by 'npm run build'. Both already run in build.yml and release.yml. Also drop the now-obsolete 'verify committed dist files match a fresh build' step in CI \u2014 dist files are no longer tracked. * refactor(filament): remove is_file guards around admin assets The guards were added when composer post-autoload-dump could fire before `npm run build` produced the frontend artifacts. CI now does a three-step bootstrap (composer install --no-scripts -> npm build -> composer dump-autoload) so dist files and the Vite manifest always exist by the time AdminPanelProvider::register() runs. Release tarballs ship them pre-built. If assets are missing at runtime we want to know immediately, not silently degrade the admin panel. * refactor(filament): mirror Pireps namespace under views/filament/pireps Co-locate page views and partials under resources/views/filament/pireps/ to mirror App\Filament\Resources\Pireps namespace. - pages/{list-pireps,view-pirep}.blade.php (was filament/resources/pireps/pages/) - partials/row.blade.php + partials/detail/* (was filament/pireps/{row,detail}/) - Update $view in ListPireps and ViewPirep - Rewrite 6 @include paths * chore(build): add npm clean script to remove built assets Removes public/build (Vite output) and resources/js/dist (esbuild components output) in one command. * Add the fillament public assets back in * Move the vite js into the page render hook, lazily load map on first use
This commit is contained in:
parent
8dfba75b0f
commit
de34422008
232
.env.example
232
.env.example
@ -1,5 +1,5 @@
|
||||
# THIS FILE MUST BE KEPT SECRET! IT IS BLOCKED IN THE HTACCESS FILE
|
||||
# HOWEVER, THIS DIRECTORY SHOULDN'T BE EXPOSED TO THE PUBLIC AT ALL
|
||||
# THIS FILE MUST BE KEPT SECRET! IT IS BLOCKED IN THE HTACCESS FILE.
|
||||
# HOWEVER, THIS DIRECTORY SHOULDN'T BE EXPOSED TO THE PUBLIC AT ALL.
|
||||
# SEE THE DOCS FOR PROPER (SECURE) INSTALLATION:
|
||||
# https://docs.phpvms.net/installation/uploading
|
||||
#
|
||||
@ -10,111 +10,227 @@
|
||||
# 'some_key' = env('ENVIRONMENT_VARIABLE_KEY_ADDED_BELOW', 'default value')
|
||||
#
|
||||
# Various other settings in the configs also read from some environment variables
|
||||
# by default. You can override those here
|
||||
# by default. You can override those here.
|
||||
|
||||
# This file is provided as an example. It is pre-configured and useful when you want to use
|
||||
# docker (you need to rename it to .env).
|
||||
# If you're not using Docker, it won't be very useful to you since the phpVMS installer
|
||||
# will automatically create a .env file for you.
|
||||
# =============================================================================
|
||||
# APPLICATION CORE
|
||||
# =============================================================================
|
||||
|
||||
# The user and group used by docker
|
||||
WWWUSER=1000
|
||||
WWWGROUP=1000
|
||||
# The name of your application, shown in notifications and UI elements.
|
||||
APP_NAME=phpvms
|
||||
|
||||
# The domain name used by caddy. See https://caddyserver.com/docs/caddyfile/concepts#addresses
|
||||
CADDY_DOMAIN="localhost"
|
||||
# Application encryption key. Generate one at https://appkeyforlaravel.com
|
||||
# phpVMS also uses this to detect if it has been installed.
|
||||
APP_KEY=
|
||||
|
||||
# If you want to change the exposed ports
|
||||
#FORWARD_HTTP_PORT=8080
|
||||
#FORWARD_HTTPS_PORT=8443
|
||||
#FORWARD_DB_PORT=3307
|
||||
# The full public URL of your installation, including the scheme (e.g. https://).
|
||||
APP_URL=
|
||||
|
||||
APP_NAME=phpVMS
|
||||
APP_ENV=local
|
||||
APP_KEY=base64:1IcdcyMVAztKFFiqfJOX5w6FkOb9ONnjCA3bdxNbtQ4=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
# Environment type: production, local, staging, etc.
|
||||
# In production, set this to "production".
|
||||
APP_ENV=production
|
||||
|
||||
# Show detailed error messages with stack traces. DISABLE IN PRODUCTION.
|
||||
APP_DEBUG=false
|
||||
|
||||
# Enable the Laravel Debugbar on every page. DISABLE IN PRODUCTION.
|
||||
DEBUGBAR_ENABLED=false
|
||||
|
||||
# =============================================================================
|
||||
# LOCALIZATION
|
||||
# =============================================================================
|
||||
|
||||
# Default application locale (language code).
|
||||
APP_LOCALE=en
|
||||
|
||||
# Fallback locale when a translation is missing.
|
||||
APP_FALLBACK_LOCALE=en
|
||||
|
||||
# Locale used by Faker when generating fake data.
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
# =============================================================================
|
||||
# DATABASE
|
||||
# =============================================================================
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
# Database driver: mysql, mariadb, pgsql, sqlite, sqlsrv.
|
||||
DB_CONNECTION=mysql
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
# Host address of the database server.
|
||||
DB_HOST=127.0.0.1
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=daily
|
||||
LOG_DAILY_DAYS=3
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
# Port the database server listens on.
|
||||
DB_PORT=3306
|
||||
|
||||
## DATABASE SETTINGS
|
||||
# Name of the database to use.
|
||||
DB_DATABASE=phpvms
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
#DB_HOST='mariadb'
|
||||
#DB_PORT=3306
|
||||
#DB_DATABASE='' # Enter your database name
|
||||
#DB_USERNAME='' # Enter your MySQL username
|
||||
#DB_PASSWORD='' # Enter your MySQL password (will also be used as root password)
|
||||
#DB_PREFIX=''
|
||||
# Database user credentials.
|
||||
DB_USERNAME=
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
# Optional table prefix (uncomment if needed).
|
||||
# DB_PREFIX=''
|
||||
|
||||
# =============================================================================
|
||||
# PAGINATION
|
||||
# =============================================================================
|
||||
|
||||
# Default number of items per page for paginated endpoints.
|
||||
PHPVMS_PAGINATION_LIMIT=50
|
||||
|
||||
# Hard cap on the number of items per page (prevents oversized API requests).
|
||||
PHPVMS_PAGINATION_MAX=100
|
||||
|
||||
# =============================================================================
|
||||
# CACHE
|
||||
# =============================================================================
|
||||
|
||||
# Default cache store: file, database, redis, memcached, dynamodb, array.
|
||||
CACHE_STORE=file
|
||||
|
||||
# =============================================================================
|
||||
# SESSION
|
||||
# =============================================================================
|
||||
|
||||
# Session driver: file, cookie, database, redis, memcached, dynamodb, array.
|
||||
SESSION_DRIVER=file
|
||||
|
||||
# Minutes a session can remain idle before expiring.
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
# Encrypt all session data before storage.
|
||||
SESSION_ENCRYPT=false
|
||||
|
||||
# Path the session cookie is available on.
|
||||
SESSION_PATH=/
|
||||
|
||||
# Domain the session cookie is available to (null = current domain).
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
# =============================================================================
|
||||
# QUEUE
|
||||
# =============================================================================
|
||||
|
||||
# Queue connection: sync, database, redis, beanstalkd, sqs.
|
||||
# "database" or "redis" is recommended for production.
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=file
|
||||
# CACHE_PREFIX=
|
||||
# If true, queued jobs run during the cron instead of via a dedicated worker.
|
||||
# Only use this on shared hosting where you cannot run queue:work.
|
||||
RUN_QUEUED_JOBS_IN_CRON=false
|
||||
|
||||
# =============================================================================
|
||||
# BROADCASTING
|
||||
# =============================================================================
|
||||
|
||||
# Broadcasting driver: log, pusher, ably, redis, null.
|
||||
BROADCAST_CONNECTION=log
|
||||
|
||||
# =============================================================================
|
||||
# FILESYSTEM
|
||||
# =============================================================================
|
||||
|
||||
# Default storage disk: local, public, s3, r2, sftp.
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# =============================================================================
|
||||
# REDIS
|
||||
# =============================================================================
|
||||
|
||||
# Redis client library: phpredis or predis.
|
||||
REDIS_CLIENT=phpredis
|
||||
|
||||
# Redis server connection details.
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Look at the available mail configs in config/mail.php
|
||||
# Also refer to the Laravel docs here: https://laravel.com/docs/8.x/mail
|
||||
# If you're using SMTP, I recommend setting the QUEUE_DRIVER to 'database'
|
||||
# https://docs.phpvms.net/config/optimizing#queue-driver
|
||||
# =============================================================================
|
||||
# LOGGING
|
||||
# =============================================================================
|
||||
|
||||
# Maintenance mode driver: file or cache.
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
# Minimum log level: debug, info, notice, warning, error, critical, alert, emergency.
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Comma-separated list of log channels (e.g. "daily,slack").
|
||||
# See config/logging.php for available channels.
|
||||
LOG_STACK=daily
|
||||
|
||||
# How many days daily log files are kept.
|
||||
LOG_DAILY_DAYS=7
|
||||
|
||||
# =============================================================================
|
||||
# MAIL
|
||||
# =============================================================================
|
||||
|
||||
# Mail transport: smtp, sendmail, mailgun, ses, postmark, resend, log, array.
|
||||
MAIL_MAILER=log
|
||||
|
||||
# Connection scheme for SMTP (e.g. tls, ssl, null).
|
||||
MAIL_SCHEME=null
|
||||
|
||||
# SMTP server host and port.
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
|
||||
# SMTP authentication credentials.
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
|
||||
# The "From" address and name for all outgoing emails.
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# =============================================================================
|
||||
# AWS / S3
|
||||
# =============================================================================
|
||||
|
||||
# AWS credentials used for S3, SES, SQS, and DynamoDB.
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
# =============================================================================
|
||||
# FRONTEND
|
||||
# =============================================================================
|
||||
|
||||
# Injected into the Vite build manifest as the app name.
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# If you're not using the Laravel Queue Worker, set this to true. This will run the queued jobs
|
||||
# in the cron. This is an alternative for some shared hosting providers but it's not recommended
|
||||
# https://laravel.com/docs/queues#running-the-queue-worker
|
||||
RUN_QUEUED_JOBS_IN_CRON=false
|
||||
# =============================================================================
|
||||
# ADMIN / phpVMS SPECIFIC
|
||||
# =============================================================================
|
||||
|
||||
# Whether to prefetch the data in the admin panel
|
||||
# This will speed up the admin panel, but will use more bandwidth
|
||||
# See https://filamentphp.com/docs/4.x/panel-configuration#enabling-spa-prefetching
|
||||
# Enable SPA-style prefetching in the Filament admin panel.
|
||||
# Speeds up navigation but uses more bandwidth.
|
||||
# https://filamentphp.com/docs/4.x/panel-configuration#enabling-spa-prefetching
|
||||
USE_PREFETCHING_IN_ADMIN=false
|
||||
|
||||
# SAIL SETTINGS
|
||||
# Change the default docker-compose name in sail
|
||||
# DO NOT EDIT THIS
|
||||
SAIL_FILES="docker-compose.sail.yml"
|
||||
# =============================================================================
|
||||
# MISC / SECURITY
|
||||
# =============================================================================
|
||||
|
||||
# Number of hashing rounds for password hashing.
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
# =============================================================================
|
||||
# DEVELOPMENT (Laravel Sail / Docker)
|
||||
# =============================================================================
|
||||
|
||||
# Host user and group IDs for file ownership inside Sail containers.
|
||||
WWWUSER=1000
|
||||
WWWGROUP=1000
|
||||
|
||||
# Sail compose file override.
|
||||
SAIL_FILES="compose.sail.yml"
|
||||
|
||||
# Optional: expose different ports on the host.
|
||||
# FORWARD_HTTP_PORT=8080
|
||||
# FORWARD_HTTPS_PORT=8443
|
||||
# FORWARD_DB_PORT=3307
|
||||
|
||||
69
.github/workflows/build.yml
vendored
69
.github/workflows/build.yml
vendored
@ -73,15 +73,21 @@ jobs:
|
||||
coverage: xdebug
|
||||
tools: php-cs-fixer, phpunit
|
||||
|
||||
- name: Configure Environment
|
||||
# Bootstrap order has to thread a needle:
|
||||
# 1. composer install --no-scripts: pulls vendor/ (needed by Vite
|
||||
# because theme.css @imports vendor/filament/filament/resources/css)
|
||||
# WITHOUT firing post-autoload-dump (which boots service providers
|
||||
# that read the Vite manifest + dist files we haven't built yet).
|
||||
# 2. npm install + npm run build: produces public/build/manifest.json
|
||||
# and (re)writes resources/js/dist/admin/components/*.js.
|
||||
# 3. composer dump-autoload: re-fires post-autoload-dump now that the
|
||||
# frontend artifacts exist; package:discover and filament:upgrade
|
||||
# boot providers cleanly.
|
||||
- name: Install Composer dependencies (no scripts)
|
||||
run: |
|
||||
php --version
|
||||
composer install --dev --no-interaction --verbose
|
||||
composer install --dev --no-interaction --no-scripts --verbose
|
||||
composer global require laravel/pint
|
||||
composer dump-autoload -o
|
||||
cp .github/scripts/env.test .env
|
||||
cp .github/scripts/phpunit.xml phpunit.xml
|
||||
.github/scripts/version.sh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@ -101,6 +107,13 @@ jobs:
|
||||
- name: Compile assets
|
||||
run: npm run build
|
||||
|
||||
- name: Run Composer post-autoload scripts
|
||||
run: |
|
||||
composer dump-autoload -o
|
||||
cp .github/scripts/env.test .env
|
||||
cp .github/scripts/phpunit.xml phpunit.xml
|
||||
.github/scripts/version.sh
|
||||
|
||||
- name: Run Pint
|
||||
run: pint --test --parallel
|
||||
|
||||
@ -160,20 +173,31 @@ jobs:
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
# Dependencies
|
||||
- name: "Install Release Dependencies"
|
||||
# Three-step bootstrap (see `build` job comment for full rationale):
|
||||
# 1. composer install --no-scripts: vendor/ for Vite CSS @imports
|
||||
# 2. npm build: writes public/build manifest + dist files
|
||||
# 3. composer dump-autoload: re-fires post-autoload-dump cleanly
|
||||
- name: "Install Release Dependencies (no scripts)"
|
||||
run: |
|
||||
rm -rf vendor
|
||||
composer install --no-dev --prefer-dist --no-interaction --verbose
|
||||
composer dump-autoload
|
||||
composer install --no-dev --prefer-dist --no-interaction --no-scripts --verbose
|
||||
sudo chmod +x ./.github/scripts/*
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
- name: Install NPM dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Compile assets
|
||||
run: npm run build
|
||||
|
||||
- name: Run Composer post-autoload scripts
|
||||
run: composer dump-autoload
|
||||
|
||||
- id: version
|
||||
name: Get version
|
||||
run: .github/scripts/version.sh
|
||||
@ -233,14 +257,31 @@ jobs:
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
# Dependencies
|
||||
- name: "Install Release Dependencies"
|
||||
# Three-step bootstrap (see `build` job comment for full rationale):
|
||||
# 1. composer install --no-scripts: vendor/ for Vite CSS @imports
|
||||
# 2. npm build: writes public/build manifest + dist files
|
||||
# 3. composer dump-autoload: re-fires post-autoload-dump cleanly
|
||||
- name: "Install Release Dependencies (no scripts)"
|
||||
run: |
|
||||
rm -rf vendor
|
||||
composer install --no-dev --prefer-dist --no-interaction --verbose
|
||||
composer dump-autoload
|
||||
composer install --no-dev --prefer-dist --no-interaction --no-scripts --verbose
|
||||
sudo chmod +x ./.github/scripts/*
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
- name: Install NPM dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Compile assets
|
||||
run: npm run build
|
||||
|
||||
- name: Run Composer post-autoload scripts
|
||||
run: composer dump-autoload
|
||||
|
||||
# https://github.com/marketplace/actions/nerdbank-gitversioning
|
||||
- name: Nerdbank.GitVersioning
|
||||
uses: dotnet/nbgv@v0.4.2
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -18,6 +18,10 @@ app/storage/
|
||||
public/storage
|
||||
public/build
|
||||
public/hot
|
||||
public/js/app
|
||||
# esbuild output for Filament AlpineComponents (built by `node bin/build.js`).
|
||||
# Built in CI; not committed.
|
||||
resources/js/dist
|
||||
storage/*.key
|
||||
storage/settings.json
|
||||
storage/*.sqlite
|
||||
@ -96,4 +100,8 @@ boost.json
|
||||
/.serena/
|
||||
|
||||
# Modules
|
||||
modules_statuses.json
|
||||
modules_statuses.json
|
||||
.superpowers/
|
||||
|
||||
# Local SQLite scratch db (test artifact)
|
||||
/phpvms
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
"ignorePatterns": [
|
||||
"public/**",
|
||||
"modules/**",
|
||||
"resources/js/dist/**",
|
||||
".github/skills/*",
|
||||
".github/copilot-instructions.md"
|
||||
]
|
||||
|
||||
@ -36,5 +36,5 @@
|
||||
"builtin": true
|
||||
},
|
||||
"globals": {},
|
||||
"ignorePatterns": ["public/**"]
|
||||
"ignorePatterns": ["public/**", "resources/js/dist/**"]
|
||||
}
|
||||
|
||||
1
.php-cs-fixer.cache
Normal file
1
.php-cs-fixer.cache
Normal file
@ -0,0 +1 @@
|
||||
{"php":"8.5.6","version":"3.95.2","indent":" ","lineEnding":"\n","rules":{"nullable_type_declaration":true,"operator_linebreak":true,"ordered_types":{"null_adjustment":"always_last","sort_algorithm":"none"},"single_class_element_per_statement":true,"types_spaces":true,"array_indentation":true,"array_syntax":true,"attribute_block_no_spaces":true,"cast_spaces":true,"concat_space":{"spacing":"one"},"function_declaration":{"closure_fn_spacing":"none"},"method_argument_space":{"after_heredoc":true},"new_with_parentheses":{"anonymous_class":false},"single_line_empty_body":true,"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const","const_import","do","else","elseif","enum","final","finally","for","foreach","function","function_import","if","insteadof","interface","match","named_argument","namespace","new","private","protected","public","readonly","static","switch","trait","try","type_colon","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"trailing_comma_in_multiline":{"after_heredoc":true},"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_anonymous_functions":false,"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"simple_to_complex_string_variable":true,"octal_notation":true,"clean_namespace":true,"no_unset_cast":true,"assign_null_coalescing_to_coalesce_equal":true,"normalize_index_brace":true,"heredoc_indentation":true,"no_whitespace_before_comma_in_array":{"after_heredoc":true},"list_syntax":true,"ternary_to_null_coalescing":true},"ruleCustomisationPolicyVersion":"null-policy","hashes":{"app\/Providers\/Filament\/.conform.2216064.AdminPanelProvider.php":"dbcf24e3de512db92d0a0a16dc0e3984","app\/Providers\/Filament\/.conform.2830280.AdminPanelProvider.php":"5a3385515be44a2a12fdbae398c3e590"}}
|
||||
27
.php-cs-fixer.dist.php
Normal file
27
.php-cs-fixer.dist.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PhpCsFixer\Config;
|
||||
use PhpCsFixer\Finder;
|
||||
|
||||
return (new Config())
|
||||
->setRiskyAllowed(false)
|
||||
->setRules([
|
||||
'@auto' => true,
|
||||
])
|
||||
// 💡 by default, Fixer looks for `*.php` files excluding `./vendor/` - here, you can groom this config
|
||||
->setFinder(
|
||||
(new Finder())
|
||||
// 💡 root folder to check
|
||||
->in(__DIR__)
|
||||
// 💡 additional files, eg bin entry file
|
||||
// ->append([__DIR__.'/bin-entry-file'])
|
||||
// 💡 folders to exclude, if any
|
||||
// ->exclude([/* ... */])
|
||||
// 💡 path patterns to exclude, if any
|
||||
// ->notPath([/* ... */])
|
||||
// 💡 extra configs
|
||||
// ->ignoreDotFiles(false) // true by default in v3, false in v4 or future mode
|
||||
// ->ignoreVCS(true) // true by default
|
||||
);
|
||||
22
.phpactor.json
Normal file
22
.phpactor.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "/phpactor.schema.json",
|
||||
"language_server_phpstan.enabled": false,
|
||||
"indexer.include_patterns": [
|
||||
"/app/**/*.php",
|
||||
"/config/**/*.php",
|
||||
"/database/**/*.php",
|
||||
"/modules/**/*.php",
|
||||
"/routes/**/*.php",
|
||||
"/tests/**/*.php",
|
||||
"/vendor/**/*.php"
|
||||
],
|
||||
"indexer.exclude_patterns": [
|
||||
"/vendor/**/Tests/**/*.php",
|
||||
"/vendor/**/tests/**/*.php",
|
||||
"/storage/**/*.php",
|
||||
"/bootstrap/cache/**/*.php"
|
||||
],
|
||||
"language_server.diagnostics_on_update": false,
|
||||
"language_server.diagnostics_on_save": true,
|
||||
"language_server.diagnostics_on_open": true
|
||||
}
|
||||
@ -9,9 +9,9 @@ enum NavigationGroup: string implements HasLabel
|
||||
{
|
||||
use HasSelect;
|
||||
|
||||
case Config = 'Config';
|
||||
case Operations = 'Operations';
|
||||
case Modules = 'Modules';
|
||||
case Config = 'Config';
|
||||
case AddOns = 'Add-Ons';
|
||||
case Developers = 'Developers';
|
||||
|
||||
public function getLabel(): string
|
||||
@ -19,7 +19,7 @@ enum NavigationGroup: string implements HasLabel
|
||||
return match ($this) {
|
||||
self::Config => __('filament.config'),
|
||||
self::Operations => __('filament.operations'),
|
||||
self::Modules => __('filament.modules'),
|
||||
self::AddOns => __('filament.addons'),
|
||||
self::Developers => __('filament.developers'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Nwidart\Modules\Facades\Module;
|
||||
|
||||
class Modules extends Page implements Tables\Contracts\HasTable
|
||||
class Addons extends Page implements Tables\Contracts\HasTable
|
||||
{
|
||||
use HasPageShield;
|
||||
use Tables\Concerns\InteractsWithTable;
|
||||
@ -32,7 +32,7 @@ class Modules extends Page implements Tables\Contracts\HasTable
|
||||
#[\Override]
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return Str::of(__('common.module'))->plural();
|
||||
return Str::of(__('common.addons'))->plural();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
@ -57,7 +57,7 @@ class Modules extends Page implements Tables\Contracts\HasTable
|
||||
->visible(fn (array $record): bool => !$record['enabled'])
|
||||
->action(function (array $record): void {
|
||||
app(ModuleService::class)->updateModule($record['name'], true);
|
||||
$this->redirectRoute('filament.admin.pages.modules'); // Reload the page to refresh everything
|
||||
$this->redirectRoute('filament.admin.pages.addons');
|
||||
}),
|
||||
|
||||
Action::make('disable')
|
||||
@ -67,7 +67,7 @@ class Modules extends Page implements Tables\Contracts\HasTable
|
||||
->visible(fn (array $record): bool => $record['enabled'])
|
||||
->action(function (array $record): void {
|
||||
app(ModuleService::class)->updateModule($record['name'], false);
|
||||
$this->redirectRoute('filament.admin.pages.modules'); // Reload the page to refresh everything
|
||||
$this->redirectRoute('filament.admin.pages.addons');
|
||||
}),
|
||||
|
||||
Action::make('delete')
|
||||
@ -78,7 +78,7 @@ class Modules extends Page implements Tables\Contracts\HasTable
|
||||
->requiresConfirmation()
|
||||
->action(function (array $record): void {
|
||||
app(ModuleService::class)->deleteModule($record['name']);
|
||||
$this->redirectRoute('filament.admin.pages.modules'); // Reload the page to refresh everything
|
||||
$this->redirectRoute('filament.admin.pages.addons');
|
||||
}),
|
||||
])
|
||||
->records(fn (?string $sortColumn, ?string $sortDirection, ?string $search): Collection => $this->getModulesRecords()
|
||||
40
app/Filament/Plugins/ClearCachesPlugin.php
Normal file
40
app/Filament/Plugins/ClearCachesPlugin.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Plugins;
|
||||
|
||||
use App\Livewire\Filament\ClearCaches;
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
final class ClearCachesPlugin implements Plugin
|
||||
{
|
||||
public static function make(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'clear-caches';
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel->renderHook(
|
||||
PanelsRenderHook::USER_MENU_BEFORE,
|
||||
fn (): string => Blade::render(
|
||||
'@livewire($component)',
|
||||
['component' => ClearCaches::class],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ use Filament\Panel;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
class ModuleLinksPlugin implements Plugin
|
||||
{
|
||||
@ -27,13 +28,13 @@ class ModuleLinksPlugin implements Plugin
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
// Render in the topbar (wide screen)
|
||||
$panel->renderHook(PanelsRenderHook::TOPBAR_LOGO_AFTER, fn (): Factory|\Illuminate\Contracts\View\View => view('filament.plugins.module-links-topbar', [
|
||||
$panel->renderHook(PanelsRenderHook::TOPBAR_LOGO_AFTER, fn (): Factory|View => view('filament.plugins.module-links-topbar', [
|
||||
'current_panel' => Filament::getCurrentOrDefaultPanel(),
|
||||
'group' => $this->getGroup(),
|
||||
]));
|
||||
|
||||
// Render in the sidebar (mobile)
|
||||
$panel->renderHook(PanelsRenderHook::SIDEBAR_NAV_END, fn (): Factory|\Illuminate\Contracts\View\View => view('filament.plugins.module-links-sidebar', [
|
||||
$panel->renderHook(PanelsRenderHook::SIDEBAR_NAV_END, fn (): Factory|View => view('filament.plugins.module-links-sidebar', [
|
||||
'group' => $this->getGroup(),
|
||||
]));
|
||||
}
|
||||
@ -70,7 +71,7 @@ class ModuleLinksPlugin implements Plugin
|
||||
->icon(Heroicon::OutlinedFolder);
|
||||
}
|
||||
|
||||
$group = \App\Enums\NavigationGroup::Modules;
|
||||
$group = \App\Enums\NavigationGroup::AddOns;
|
||||
|
||||
return NavigationGroup::make($group->name)
|
||||
->label($group->getLabel())
|
||||
|
||||
41
app/Filament/Plugins/SidebarCollapseTogglePlugin.php
Normal file
41
app/Filament/Plugins/SidebarCollapseTogglePlugin.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Plugins;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
/**
|
||||
* Renders a collapse/expand button at the bottom of the sidebar instead of
|
||||
* relying on Filament's default hamburger in the topbar. The default topbar
|
||||
* button is hidden via CSS (see resources/css/filament/admin/theme.css).
|
||||
*/
|
||||
final class SidebarCollapseTogglePlugin implements Plugin
|
||||
{
|
||||
public static function make(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'sidebar-collapse-toggle';
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel->renderHook(
|
||||
PanelsRenderHook::SIDEBAR_FOOTER,
|
||||
fn (): View => view('filament.plugins.sidebar-collapse-toggle'),
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,7 @@ class AirlineForm
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('filament.airline_informations'))
|
||||
Section::make(__('filament.airline_information'))
|
||||
->schema([
|
||||
TextInput::make('icao')
|
||||
->label('ICAO (3LD)')
|
||||
|
||||
@ -5,12 +5,11 @@ namespace App\Filament\Resources\Airports;
|
||||
use App\Enums\NavigationGroup;
|
||||
use App\Filament\RelationManagers\ExpensesRelationManager;
|
||||
use App\Filament\RelationManagers\FilesRelationManager;
|
||||
use App\Filament\Resources\Airports\Pages\CreateAirport;
|
||||
use App\Filament\Resources\Airports\Pages\EditAirport;
|
||||
use App\Filament\Resources\Airports\Pages\ListAirports;
|
||||
use App\Filament\Resources\Airports\Schemas\AirportForm;
|
||||
use App\Filament\Resources\Airports\Tables\AirportsTable;
|
||||
use App\Models\Airport;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
@ -19,30 +18,32 @@ use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
use Override;
|
||||
use UnitEnum;
|
||||
|
||||
class AirportResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Airport::class;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Config;
|
||||
protected static string|UnitEnum|null $navigationGroup = NavigationGroup::Config;
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = Heroicon::OutlinedMapPin;
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedMapPin;
|
||||
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return AirportForm::configure($schema);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return AirportsTable::configure($table);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
@ -51,17 +52,15 @@ class AirportResource extends Resource
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListAirports::route('/'),
|
||||
'create' => CreateAirport::route('/create'),
|
||||
'edit' => EditAirport::route('/{record}/edit'),
|
||||
'index' => ListAirports::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
@ -70,7 +69,7 @@ class AirportResource extends Resource
|
||||
]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public static function getGloballySearchableAttributes(): array
|
||||
{
|
||||
return ['name', 'icao', 'location'];
|
||||
@ -79,7 +78,7 @@ class AirportResource extends Resource
|
||||
/**
|
||||
* @param Airport $record
|
||||
*/
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public static function getGlobalSearchResultTitle(Model $record): string|Htmlable
|
||||
{
|
||||
return $record->name;
|
||||
@ -88,7 +87,7 @@ class AirportResource extends Resource
|
||||
/**
|
||||
* @param Airport $record
|
||||
*/
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public static function getGlobalSearchResultDetails(Model $record): array
|
||||
{
|
||||
return [
|
||||
@ -96,7 +95,7 @@ class AirportResource extends Resource
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('common.airport');
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Airports\Pages;
|
||||
|
||||
use App\Filament\Resources\Airports\AirportResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateAirport extends CreateRecord
|
||||
{
|
||||
protected static string $resource = AirportResource::class;
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Airports\Pages;
|
||||
|
||||
use App\Filament\Resources\Airports\AirportResource;
|
||||
use App\Models\Airport;
|
||||
use App\Models\File;
|
||||
use App\Services\FileService;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditAirport extends EditRecord
|
||||
{
|
||||
protected static string $resource = AirportResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
ForceDeleteAction::make()->before(function (Airport $record): void {
|
||||
$record->files()->each(function (File $file): void {
|
||||
app(FileService::class)->removeFile($file);
|
||||
});
|
||||
}),
|
||||
RestoreAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -13,12 +13,13 @@ use Filament\Actions\ExportAction;
|
||||
use Filament\Actions\ImportAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Override;
|
||||
|
||||
class ListAirports extends ListRecords
|
||||
{
|
||||
protected static string $resource = AirportResource::class;
|
||||
|
||||
#[\Override]
|
||||
#[Override]
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -8,7 +8,7 @@ use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use League\ISO3166\ISO3166;
|
||||
@ -20,7 +20,9 @@ class AirportForm
|
||||
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('filament.airport_informations'))
|
||||
Grid::make([
|
||||
'default' => 4,
|
||||
])
|
||||
->schema([
|
||||
TextInput::make('icao')
|
||||
->label('ICAO')
|
||||
|
||||
@ -26,7 +26,7 @@ class AwardForm
|
||||
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('filament.awards_informations'))
|
||||
Section::make(__('filament.awards_information'))
|
||||
->description(__('filament.awards_description'))
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
|
||||
@ -17,7 +17,7 @@ class FareForm
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('filament.fare_informations'))
|
||||
Section::make(__('filament.fare_information'))
|
||||
->description(__('filament.fare_description'))
|
||||
->schema([
|
||||
TextInput::make('code')
|
||||
|
||||
@ -29,7 +29,7 @@ class FlightResource extends Resource
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = Heroicon::OutlinedAdjustmentsVertical;
|
||||
protected static string|\BackedEnum|null $navigationIcon = Heroicon::OutlinedMap;
|
||||
|
||||
#[\Override]
|
||||
public static function form(Schema $schema): Schema
|
||||
|
||||
@ -26,7 +26,7 @@ class FlightForm
|
||||
return $schema
|
||||
->components([
|
||||
Grid::make()->schema([
|
||||
Section::make(__('filament.flight_informations'))
|
||||
Section::make(__('filament.flight_information'))
|
||||
->schema([
|
||||
Select::make('airline_id')
|
||||
->label(__('common.airline'))
|
||||
@ -65,7 +65,7 @@ class FlightForm
|
||||
|
||||
TimePicker::make('flight_time')
|
||||
->seconds(false)
|
||||
->label(__('flights.flighttime'))
|
||||
->label(__('flights.flight_time'))
|
||||
->native(false)
|
||||
->required(),
|
||||
|
||||
|
||||
72
app/Filament/Resources/News/NewsResource.php
Normal file
72
app/Filament/Resources/News/NewsResource.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\News;
|
||||
|
||||
use App\Enums\NavigationGroup;
|
||||
use App\Filament\Resources\News\Pages\ListNews;
|
||||
use App\Filament\Resources\News\Schemas\NewsForm;
|
||||
use App\Filament\Resources\News\Tables\NewsTable;
|
||||
use App\Models\News;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Override;
|
||||
use UnitEnum;
|
||||
|
||||
class NewsResource extends Resource
|
||||
{
|
||||
protected static ?string $model = News::class;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = NavigationGroup::Operations;
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedNewspaper;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'subject';
|
||||
|
||||
#[Override]
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return NewsForm::configure($schema);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return NewsTable::configure($table);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListNews::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public static function getGloballySearchableAttributes(): array
|
||||
{
|
||||
return ['subject', 'body'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param News $record
|
||||
*/
|
||||
#[Override]
|
||||
public static function getGlobalSearchResultTitle(Model $record): string|Htmlable
|
||||
{
|
||||
return $record->subject;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('common.news');
|
||||
}
|
||||
}
|
||||
38
app/Filament/Resources/News/Pages/ListNews.php
Normal file
38
app/Filament/Resources/News/Pages/ListNews.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\News\Pages;
|
||||
|
||||
use App\Events\NewsAdded;
|
||||
use App\Filament\Resources\News\NewsResource;
|
||||
use App\Models\News;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Override;
|
||||
|
||||
class ListNews extends ListRecords
|
||||
{
|
||||
protected static string $resource = NewsResource::class;
|
||||
|
||||
#[Override]
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->icon(Heroicon::OutlinedPlusCircle)
|
||||
->mutateDataUsing(function (array $data): array {
|
||||
$data['user_id'] = Auth::id();
|
||||
|
||||
return $data;
|
||||
})
|
||||
->after(function (array $data, News $record): void {
|
||||
if (get_truth_state($data['send_notifications'] ?? false)) {
|
||||
event(new NewsAdded($record));
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
38
app/Filament/Resources/News/Schemas/NewsForm.php
Normal file
38
app/Filament/Resources/News/Schemas/NewsForm.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\News\Schemas;
|
||||
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
|
||||
class NewsForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('subject')
|
||||
->label(__('filament.news_subject'))
|
||||
->string()
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
|
||||
RichEditor::make('body')
|
||||
->label(__('filament.news_body'))
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
|
||||
Toggle::make('send_notifications')
|
||||
->label(__('filament.news_send_notifications'))
|
||||
->dehydrated(false)
|
||||
->default(false)
|
||||
->onColor('success')
|
||||
->onIcon(Heroicon::CheckCircle)
|
||||
->offColor('danger')
|
||||
->offIcon(Heroicon::XCircle),
|
||||
]);
|
||||
}
|
||||
}
|
||||
61
app/Filament/Resources/News/Tables/NewsTable.php
Normal file
61
app/Filament/Resources/News/Tables/NewsTable.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\News\Tables;
|
||||
|
||||
use App\Events\NewsUpdated;
|
||||
use App\Filament\Resources\News\Schemas\NewsForm;
|
||||
use App\Models\News;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class NewsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('subject')
|
||||
->label(__('filament.news_subject'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(60),
|
||||
|
||||
TextColumn::make('user.name')
|
||||
->label(trans_choice('common.user', 1))
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('common.created_at'))
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('updated_at')
|
||||
->label(__('common.updated_at'))
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make()
|
||||
->schema(fn (Schema $schema): Schema => NewsForm::configure($schema))
|
||||
->after(function (array $data, News $record): void {
|
||||
if (get_truth_state($data['send_notifications'] ?? false)) {
|
||||
event(new NewsUpdated($record));
|
||||
}
|
||||
}),
|
||||
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,7 @@ class PageForm
|
||||
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('filament.page_informations'))
|
||||
Section::make(__('filament.page_information'))
|
||||
->schema([
|
||||
Grid::make()
|
||||
->schema([
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Pireps\Actions;
|
||||
|
||||
use App\Models\Pirep;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
|
||||
class ViewAction
|
||||
{
|
||||
public static function make(): Action
|
||||
{
|
||||
return Action::make('view')
|
||||
->color('info')
|
||||
->icon(Heroicon::Eye)
|
||||
->label(__('pireps.view_pirep'))
|
||||
->url(fn (Pirep $record): string => route('frontend.pireps.show', $record->id))
|
||||
->openUrlInNewTab();
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Pireps\Pages;
|
||||
|
||||
use App\Enums\PirepState;
|
||||
use App\Filament\Resources\Pireps\Actions\PirepFieldsAction;
|
||||
use App\Filament\Resources\Pireps\PirepResource;
|
||||
use App\Filament\Resources\Pireps\Widgets\PirepStats;
|
||||
use Filament\Pages\Concerns\ExposesTableToWidgets;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ListPireps extends ListRecords
|
||||
{
|
||||
@ -17,6 +17,21 @@ class ListPireps extends ListRecords
|
||||
|
||||
protected static string $resource = PirepResource::class;
|
||||
|
||||
/**
|
||||
* Custom blade view that renders pireps as cards instead of an embedded table.
|
||||
* The page still extends ListRecords so Filament wires the Table object's
|
||||
* filters, search, sort, and pagination via Livewire — we just don't render
|
||||
* the table markup.
|
||||
*/
|
||||
protected string $view = 'filament.pireps.pages.list-pireps';
|
||||
|
||||
#[\Override]
|
||||
public function content(Schema $schema): Schema
|
||||
{
|
||||
// No EmbeddedTable. The custom blade renders filters + cards directly.
|
||||
return $schema->components([]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
@ -32,15 +47,4 @@ class ListPireps extends ListRecords
|
||||
PirepStats::class,
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getTabs(): array
|
||||
{
|
||||
return [
|
||||
'all' => Tab::make()->label(__('filament-tables::table.filters.multi_select.placeholder')),
|
||||
'pending' => Tab::make()->label(PirepState::PENDING->getLabel())->modifyQueryUsing(fn (Builder $query) => $query->where('state', PirepState::PENDING)),
|
||||
'rejected' => Tab::make()->label(PirepState::REJECTED->getLabel())->modifyQueryUsing(fn (Builder $query) => $query->where('state', PirepState::REJECTED)),
|
||||
'accepted' => Tab::make()->label(PirepState::ACCEPTED->getLabel())->modifyQueryUsing(fn (Builder $query) => $query->where('state', PirepState::ACCEPTED)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
215
app/Filament/Resources/Pireps/Pages/ViewPirep.php
Normal file
215
app/Filament/Resources/Pireps/Pages/ViewPirep.php
Normal file
@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Pireps\Pages;
|
||||
|
||||
use App\Enums\AcarsType;
|
||||
use App\Filament\Resources\Pireps\Actions\AcceptAction;
|
||||
use App\Filament\Resources\Pireps\Actions\RejectAction;
|
||||
use App\Filament\Resources\Pireps\PirepResource;
|
||||
use App\Models\Acars;
|
||||
use App\Models\Pirep;
|
||||
use App\Services\Finance\PirepFinanceService;
|
||||
use App\Services\GeoService;
|
||||
use App\Services\Pirep\PerformanceChartService;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* @property Pirep $record
|
||||
*/
|
||||
class ViewPirep extends ViewRecord
|
||||
{
|
||||
protected static string $resource = PirepResource::class;
|
||||
|
||||
/**
|
||||
* GeoJSON feature collections for the route map, serialized to plain arrays
|
||||
* so Livewire can hydrate them between requests. GeoService returns
|
||||
* \GeoJson\Feature\FeatureCollection value objects which Livewire cannot
|
||||
* serialize; we convert to associative arrays in mount().
|
||||
*
|
||||
* Shape: ['planned_rte_points' => [...], 'planned_rte_line' => [...],
|
||||
* 'actual_route_points' => [...], 'actual_route_line' => [...]]
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $mapFeatures = [];
|
||||
|
||||
/**
|
||||
* Chart.js payload for the Performance card. Null when the PIREP has no
|
||||
* ACARS samples — blade switches to the empty stub.
|
||||
*
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $performance = null;
|
||||
|
||||
/**
|
||||
* Sort direction for the Flight Log timeline. Toggled between 'asc'
|
||||
* (earliest first) and 'desc' (latest first) via wire:click.
|
||||
*/
|
||||
public string $logSort = 'asc';
|
||||
|
||||
/**
|
||||
* Custom blade view that renders the PIREP detail layout.
|
||||
* The page extends ViewRecord so Filament resolves the record from the
|
||||
* URL and applies policy checks; we just opt out of the default infolist
|
||||
* rendering and provide our own markup.
|
||||
*/
|
||||
protected string $view = 'filament.pireps.pages.view-pirep';
|
||||
|
||||
#[\Override]
|
||||
public function getHeading(): string
|
||||
{
|
||||
$record = $this->record;
|
||||
$parts = [$record->ident];
|
||||
if ($record->aircraft) {
|
||||
$parts[] = $record->aircraft->registration;
|
||||
}
|
||||
|
||||
$parts[] = $record->dpt_airport_id.'→'.$record->arr_airport_id;
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function content(Schema $schema): Schema
|
||||
{
|
||||
// No default infolist — the custom blade renders the detail layout.
|
||||
return $schema->components([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate finances for this PIREP and refresh the page.
|
||||
*/
|
||||
public function recalculateFinances(): void
|
||||
{
|
||||
app(PirepFinanceService::class)->processFinancesForPirep($this->record);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(__('filament.finances_recalculated'))
|
||||
->send();
|
||||
|
||||
$this->dispatch('$refresh');
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed getter for LOG entries. Queries acars rows where type = LOG
|
||||
* and log is not null, ordered by the current $logSort direction.
|
||||
*
|
||||
* @return Collection<int, Acars>
|
||||
*/
|
||||
public function getLogEntriesProperty(): Collection
|
||||
{
|
||||
return Acars::query()
|
||||
->where('pirep_id', $this->record->id)
|
||||
->where('type', AcarsType::LOG)
|
||||
->whereNotNull('log')
|
||||
->orderBy('created_at', $this->logSort)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the Flight Log sort direction between ascending and descending.
|
||||
*/
|
||||
public function toggleLogSort(): void
|
||||
{
|
||||
$this->logSort = $this->logSort === 'asc' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip ViewRecord's default form/infolist fill. The page renders a custom
|
||||
* blade that reads $record directly, so we don't need (or want) Filament
|
||||
* to hydrate a form schema from the model attributes. Pirep has custom
|
||||
* value-object casts (Fuel, Distance) which break NumberStateCast.
|
||||
*/
|
||||
#[\Override]
|
||||
protected function fillForm(): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
AcceptAction::make(),
|
||||
RejectAction::make(),
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
ForceDeleteAction::make(),
|
||||
RestoreAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
parent::mount($record);
|
||||
|
||||
// Eager-load everything the detail blade and embedded relation managers
|
||||
// read. Lazy loading is disabled in non-production environments
|
||||
// (Model::preventLazyLoading in AppServiceProvider), so any nested
|
||||
// relation access from a blade column or RM closure must be preloaded
|
||||
// here or the request hard-fails.
|
||||
//
|
||||
// - 'user.rank' covers sidebar's $pilot->rank->name access.
|
||||
// - 'comments.user' covers CommentsRelationManager's user.name column.
|
||||
// - 'fares.fare' covers FaresRelationManager's fare column (PirepFare->fare).
|
||||
// - 'fares.pirep' + 'field_values.pirep' cover the `$record->pirep->read_only`
|
||||
// guard inside the FaresRelationManager and FieldValuesRelationManager
|
||||
// column `disabled` closures (fires when a user attempts to edit a row).
|
||||
// - 'transactions' covers TransactionsRelationManager listing.
|
||||
// - 'field_values' feeds the `fields` Attribute accessor used by the sidebar.
|
||||
// - 'fields' itself is an Attribute, not a relation — don't load it.
|
||||
$this->record->loadMissing([
|
||||
'user.rank',
|
||||
'aircraft',
|
||||
'dpt_airport',
|
||||
'arr_airport',
|
||||
'comments.user',
|
||||
'transactions',
|
||||
'fares.fare',
|
||||
'fares.pirep',
|
||||
'field_values.pirep',
|
||||
'field_values',
|
||||
]);
|
||||
|
||||
// GeoService returns FeatureCollection value objects; convert to plain
|
||||
// arrays so Livewire can serialize the property between requests.
|
||||
//
|
||||
// A malformed ACARS sample (non-numeric lat/lon, missing airport
|
||||
// relation) should not 500 the entire view — log + render without
|
||||
// the map. The blade's $hasRouteMap guard hides the map when
|
||||
// mapFeatures stays empty.
|
||||
try {
|
||||
$features = app(GeoService::class)->pirepGeoJson($this->record);
|
||||
$this->mapFeatures = json_decode((string) json_encode($features), true) ?? [];
|
||||
} catch (\Throwable $throwable) {
|
||||
Log::warning('PIREP map build failed', [
|
||||
'pirep_id' => $this->record->id,
|
||||
'error' => $throwable->getMessage(),
|
||||
]);
|
||||
$this->mapFeatures = [];
|
||||
}
|
||||
|
||||
// Build chart payload (null when no ACARS data). Same fail-soft
|
||||
// contract: bad samples should not break the page, just hide the chart.
|
||||
try {
|
||||
$this->performance = app(PerformanceChartService::class)
|
||||
->buildDatasets($this->record);
|
||||
} catch (\Throwable $throwable) {
|
||||
Log::warning('PIREP performance chart build failed', [
|
||||
'pirep_id' => $this->record->id,
|
||||
'error' => $throwable->getMessage(),
|
||||
]);
|
||||
$this->performance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,10 +6,7 @@ use App\Enums\NavigationGroup;
|
||||
use App\Enums\PirepState;
|
||||
use App\Filament\Resources\Pireps\Pages\EditPirep;
|
||||
use App\Filament\Resources\Pireps\Pages\ListPireps;
|
||||
use App\Filament\Resources\Pireps\RelationManagers\CommentsRelationManager;
|
||||
use App\Filament\Resources\Pireps\RelationManagers\FaresRelationManager;
|
||||
use App\Filament\Resources\Pireps\RelationManagers\FieldValuesRelationManager;
|
||||
use App\Filament\Resources\Pireps\RelationManagers\TransactionsRelationManager;
|
||||
use App\Filament\Resources\Pireps\Pages\ViewPirep;
|
||||
use App\Filament\Resources\Pireps\Schemas\PirepForm;
|
||||
use App\Filament\Resources\Pireps\Tables\PirepsTable;
|
||||
use App\Filament\Resources\Pireps\Widgets\PirepStats;
|
||||
@ -31,7 +28,7 @@ class PirepResource extends Resource
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = Heroicon::OutlinedCloudArrowUp;
|
||||
protected static string|\BackedEnum|null $navigationIcon = Heroicon::OutlinedClipboardDocumentList;
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
@ -55,12 +52,10 @@ class PirepResource extends Resource
|
||||
#[\Override]
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
FaresRelationManager::class,
|
||||
FieldValuesRelationManager::class,
|
||||
CommentsRelationManager::class,
|
||||
TransactionsRelationManager::class,
|
||||
];
|
||||
// Relation managers are embedded inline by the custom ViewPirep blade
|
||||
// (@livewire(CommentsRelationManager::class, ...)) and no longer
|
||||
// surface as panel tabs.
|
||||
return [];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
@ -68,6 +63,7 @@ class PirepResource extends Resource
|
||||
{
|
||||
return [
|
||||
'index' => ListPireps::route('/'),
|
||||
'view' => ViewPirep::route('/{record}'),
|
||||
'edit' => EditPirep::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ class CommentsRelationManager extends RelationManager
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('comment')
|
||||
->modifyQueryUsing(fn ($query) => $query->with('user'))
|
||||
->columns([
|
||||
TextColumn::make('user.name')
|
||||
->label(trans_choice('common.user', 1)),
|
||||
|
||||
@ -6,7 +6,6 @@ use App\Models\PirepFare;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\TextInputColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
@ -27,22 +26,21 @@ class FaresRelationManager extends RelationManager
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('code')
|
||||
->paginated(false)
|
||||
->modifyQueryUsing(fn ($query) => $query->with(['fare', 'pirep']))
|
||||
->columns([
|
||||
TextColumn::make('fare')
|
||||
->label(trans_choice('pireps.fare', 1))
|
||||
->formatStateUsing(fn (PirepFare $record): string => $record->name.' ('.$record->code.')'),
|
||||
|
||||
TextInputColumn::make('count')
|
||||
TextColumn::make('count')
|
||||
->label(__('pireps.count'))
|
||||
->disabled(fn (PirepFare $record): bool => $record->pirep->read_only)
|
||||
->step(0.01)
|
||||
->rules(['min:0']),
|
||||
->extraAttributes(['class' => 'fi-pirep-money-mono']),
|
||||
|
||||
TextInputColumn::make('price')
|
||||
TextColumn::make('price')
|
||||
->label(__('common.price'))
|
||||
->disabled(fn (PirepFare $record): bool => $record->pirep->read_only)
|
||||
->step(0.01)
|
||||
->rules(['min:0']),
|
||||
->money(setting('units.currency'))
|
||||
->extraAttributes(['class' => 'fi-pirep-money-mono']),
|
||||
|
||||
TextColumn::make('capacity')
|
||||
->label(__('common.capacity')),
|
||||
|
||||
@ -46,6 +46,7 @@ class FieldValuesRelationManager extends RelationManager
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->modifyQueryUsing(fn ($query) => $query->with('pirep'))
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('common.name')),
|
||||
|
||||
@ -8,7 +8,6 @@ use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\Summarizers\Sum;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -30,6 +29,7 @@ class TransactionsRelationManager extends RelationManager
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('memo')
|
||||
->paginated(false)
|
||||
->columns([
|
||||
TextColumn::make('memo')
|
||||
->label(__('common.memo')),
|
||||
@ -37,18 +37,14 @@ class TransactionsRelationManager extends RelationManager
|
||||
TextColumn::make('credit')
|
||||
->label(__('common.credit'))
|
||||
->color('success')
|
||||
->money(setting('units.currency'), 100)
|
||||
->summarize([
|
||||
Sum::make()->money(setting('units.currency'), 100),
|
||||
]),
|
||||
->extraAttributes(['class' => 'fi-pirep-money-mono'])
|
||||
->money(setting('units.currency'), 100),
|
||||
|
||||
TextColumn::make('debit')
|
||||
->label(__('common.debit'))
|
||||
->color('danger')
|
||||
->money(setting('units.currency'), 100)
|
||||
->summarize([
|
||||
Sum::make()->money(setting('units.currency'), 100),
|
||||
]),
|
||||
->extraAttributes(['class' => 'fi-pirep-money-mono'])
|
||||
->money(setting('units.currency'), 100),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
|
||||
@ -22,7 +22,7 @@ class PirepForm
|
||||
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('filament.basic_informations'))->schema([
|
||||
Section::make(__('filament.basic_information'))->schema([
|
||||
|
||||
TextInput::make('flight_number')
|
||||
->integer()
|
||||
@ -65,7 +65,7 @@ class PirepForm
|
||||
->disabled(fn (Pirep $record): bool => $record->read_only),
|
||||
|
||||
TimePicker::make('flight_time')
|
||||
->label(__('pireps.flighttime'))
|
||||
->label(__('pireps.flight_time'))
|
||||
->seconds(false)
|
||||
->native(false),
|
||||
|
||||
|
||||
@ -5,12 +5,7 @@ namespace App\Filament\Resources\Pireps\Tables;
|
||||
use App\Enums\PirepState;
|
||||
use App\Filament\Resources\Pireps\Actions\AcceptAction;
|
||||
use App\Filament\Resources\Pireps\Actions\RejectAction;
|
||||
use App\Filament\Resources\Pireps\PirepResource;
|
||||
use App\Filament\Resources\Users\UserResource;
|
||||
use App\Models\Airport;
|
||||
use App\Models\Pirep;
|
||||
use App\Support\Units\Time;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
@ -21,72 +16,49 @@ use Filament\Actions\RestoreAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Enums\FiltersLayout;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Table configuration for the PIREP list page.
|
||||
*
|
||||
* NOTE: The list page (ListPireps) overrides `$view` and `content()` to
|
||||
* render pireps as custom cards instead of an embedded table. The Table
|
||||
* object here is used only as a query/filter/pagination machine — its
|
||||
* columns are intentionally empty (Filament requires at least one
|
||||
* sortable column for the toolbar). Actions defined below are mounted
|
||||
* by the custom blade per-row via `mountTableAction()`.
|
||||
*/
|
||||
class PirepsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query->whereNotIn('state', [PirepState::DRAFT, PirepState::IN_PROGRESS, PirepState::CANCELLED]))
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->with(['airline', 'aircraft', 'user', 'dpt_airport:id,icao,name', 'arr_airport:id,icao,name'])
|
||||
->whereNotIn('state', [PirepState::DRAFT, PirepState::IN_PROGRESS, PirepState::CANCELLED]))
|
||||
->columns([
|
||||
TextColumn::make('ident')
|
||||
->label(trans_choice('common.flight', 1).' #')
|
||||
->searchable(['flight_number'])
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('user.name')
|
||||
->url(fn (Pirep $record): string => UserResource::getUrl('edit', ['record' => $record->user]))
|
||||
->label(trans_choice('common.user', 1))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('dpt_airport_id')
|
||||
->label(__('flights.dep'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('arr_airport_id')
|
||||
->label(__('flights.arr'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('flight_time')
|
||||
->toggleable()
|
||||
->label(__('flights.flighttime'))
|
||||
->formatStateUsing(fn (int $state): string => Time::minutesToTimeString($state))
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('aircraft')
|
||||
->toggleable()
|
||||
->label(__('common.aircraft'))
|
||||
->formatStateUsing(fn (Pirep $record): string => $record->aircraft->registration.' - '.$record->aircraft->name)
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('source')
|
||||
->label(__('pireps.source'))
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('state')
|
||||
->label(__('common.state'))
|
||||
->badge()
|
||||
->sortable(),
|
||||
|
||||
// Empty placeholder column — the custom blade view renders rows itself.
|
||||
// Filament needs at least one column for default sort/search wiring.
|
||||
TextColumn::make('submitted_at')
|
||||
->since()
|
||||
->dateTooltip('d-m-Y H:i')
|
||||
->toggleable()
|
||||
->label(__('pireps.submitted'))
|
||||
->sortable(),
|
||||
->hidden(),
|
||||
])
|
||||
->paginated([25])
|
||||
->defaultPaginationPageOption(25)
|
||||
->defaultSort('submitted_at', 'desc')
|
||||
->searchable()
|
||||
->filters([
|
||||
SelectFilter::make('state')
|
||||
->label(__('common.state'))
|
||||
->options(collect(PirepState::cases())
|
||||
->reject(fn (PirepState $state): bool => in_array($state, [PirepState::DRAFT, PirepState::IN_PROGRESS, PirepState::CANCELLED], true))
|
||||
->mapWithKeys(fn (PirepState $state): array => [$state->value => $state->getLabel()])
|
||||
->all()),
|
||||
|
||||
SelectFilter::make('airline')
|
||||
->relationship('airline', 'name')
|
||||
->label(__('common.airline'))
|
||||
@ -131,18 +103,16 @@ class PirepsTable
|
||||
)),
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->filtersLayout(FiltersLayout::Modal)
|
||||
->filtersFormColumns(2)
|
||||
->recordUrl(fn (Pirep $record): string => PirepResource::getUrl('edit', ['record' => $record]))
|
||||
->persistFiltersInSession()
|
||||
->recordActions([
|
||||
ActionGroup::make([
|
||||
AcceptAction::make(),
|
||||
RejectAction::make(),
|
||||
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
ForceDeleteAction::make(),
|
||||
RestoreAction::make(),
|
||||
]),
|
||||
AcceptAction::make(),
|
||||
RejectAction::make(),
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
ForceDeleteAction::make(),
|
||||
RestoreAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
|
||||
@ -16,7 +16,7 @@ class RankForm
|
||||
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('filament.rank_informations'))
|
||||
Section::make(__('filament.rank_information'))
|
||||
->schema([
|
||||
Grid::make()
|
||||
->schema([
|
||||
|
||||
@ -22,7 +22,7 @@ class SimBriefAirframeResource extends Resource
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = Heroicon::OutlinedPaperAirplane;
|
||||
protected static string|\BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentDuplicate;
|
||||
|
||||
#[\Override]
|
||||
public static function form(Schema $schema): Schema
|
||||
|
||||
@ -15,7 +15,7 @@ class AircraftForm
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('filament.aircraft_informations'))
|
||||
Section::make(__('filament.aircraft_information'))
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label(__('common.name'))
|
||||
|
||||
@ -61,7 +61,7 @@ class AircraftTable
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('flight_time')
|
||||
->label(__('flights.flighttime'))
|
||||
->label(__('flights.flight_time'))
|
||||
->toggleable()
|
||||
->formatStateUsing(fn (int $state): string => floor($state / 60).'h'.$state % 60 .'min')
|
||||
->sortable(),
|
||||
|
||||
@ -15,7 +15,7 @@ class SubfleetForm
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('filament.subfleet_informations'))
|
||||
Section::make(__('filament.subfleet_information'))
|
||||
->description(__('filament.subfleet_description'))
|
||||
->schema([
|
||||
Select::make('airline_id')
|
||||
|
||||
@ -15,7 +15,7 @@ class TyperatingForm
|
||||
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('filament.typerating_informations'))->schema([
|
||||
Section::make(__('filament.typerating_information'))->schema([
|
||||
TextInput::make('name')
|
||||
->label(__('common.name'))
|
||||
->required(),
|
||||
|
||||
@ -22,7 +22,7 @@ class UserForm
|
||||
->components([
|
||||
Group::make()
|
||||
->schema([
|
||||
Section::make(__('filament.basic_informations'))
|
||||
Section::make(__('filament.basic_information'))
|
||||
->schema([
|
||||
TextInput::make('pilot_id')
|
||||
->required()
|
||||
@ -53,7 +53,7 @@ class UserForm
|
||||
])
|
||||
->columnSpanFull()
|
||||
->columns(),
|
||||
Section::make(__('filament.location_informations'))
|
||||
Section::make(__('filament.location_information'))
|
||||
->schema([
|
||||
Select::make('country')
|
||||
->label(__('common.country'))
|
||||
@ -88,7 +88,7 @@ class UserForm
|
||||
->columnSpanFull()
|
||||
->columns(),
|
||||
])->columnSpan(['lg' => 2]),
|
||||
Section::make(__('filament.user_informations'))
|
||||
Section::make(__('filament.user_information'))
|
||||
->schema([
|
||||
Select::make('state')
|
||||
->label(__('common.state'))
|
||||
@ -114,7 +114,7 @@ class UserForm
|
||||
->native(false),
|
||||
|
||||
TextInput::make('transfer_time')
|
||||
->label(__('profile.transferhours'))
|
||||
->label(__('profile.transfer_hours'))
|
||||
->numeric(),
|
||||
|
||||
Select::make('roles')
|
||||
|
||||
@ -7,12 +7,11 @@ use App\Models\User;
|
||||
use App\Services\AirlineService;
|
||||
use App\Services\Installer\MigrationService;
|
||||
use App\Services\Installer\RequirementsService;
|
||||
use App\Services\Installer\SeederService;
|
||||
use App\Services\Installer\StreamedCommandsService;
|
||||
use App\Services\UserService;
|
||||
use App\Support\Countries;
|
||||
use App\Support\Utils;
|
||||
use Database\Seeders\ShieldSeeder;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -28,14 +27,16 @@ use Filament\Schemas\Components\Wizard;
|
||||
use Filament\Schemas\Components\Wizard\Step;
|
||||
use Filament\Schemas\Schema as FilamentSchema;
|
||||
use Filament\Support\Exceptions\Halt;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* @property-read FilamentSchema $form
|
||||
@ -54,7 +55,7 @@ class Installer extends Page
|
||||
public function mount(): void
|
||||
{
|
||||
try {
|
||||
if (!empty(config('app.key')) && config('app.key') !== 'base64:zdgcDqu9PM8uGWCtMxd74ZqdGJIrnw812oRMmwDF6KY=' && Schema::hasTable('users') && User::count() > 0) {
|
||||
if (Schema::hasTable('users') && User::query()->withoutGlobalScopes()->exists()) {
|
||||
Notification::make()
|
||||
->title(__('installer.already_installed'))
|
||||
->danger()
|
||||
@ -65,7 +66,6 @@ class Installer extends Page
|
||||
return;
|
||||
}
|
||||
} catch (QueryException) {
|
||||
|
||||
}
|
||||
|
||||
$this->form->fill();
|
||||
@ -95,8 +95,10 @@ class Installer extends Page
|
||||
$this->getUserAndAirlineSetupStep(),
|
||||
])
|
||||
->persistStepInQueryString()
|
||||
->submitAction(new HtmlString(Blade::render(
|
||||
<<<'BLADE'
|
||||
->submitAction(
|
||||
new HtmlString(
|
||||
Blade::render(
|
||||
<<<'BLADE'
|
||||
<x-filament::button
|
||||
type="submit"
|
||||
size="sm"
|
||||
@ -104,7 +106,9 @@ class Installer extends Page
|
||||
{{ __('installer.complete_setup') }}
|
||||
</x-filament::button>
|
||||
BLADE
|
||||
))),
|
||||
)
|
||||
)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -113,7 +117,6 @@ class Installer extends Page
|
||||
*/
|
||||
private function getRequirementsData(): array
|
||||
{
|
||||
|
||||
$reqSvc = app(RequirementsService::class);
|
||||
|
||||
$php_version = $reqSvc->checkPHPVersion();
|
||||
@ -143,7 +146,10 @@ class Installer extends Page
|
||||
Log::error('Error while trying to connect to the database', [$exception]);
|
||||
$db = [
|
||||
'passed' => false,
|
||||
'msg' => __('installer.db_connection_failed', ['exception' => $exception->getMessage()]),
|
||||
'msg' => __(
|
||||
'installer.db_connection_failed',
|
||||
['exception' => $exception->getMessage()]
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@ -177,44 +183,30 @@ class Installer extends Page
|
||||
->label(__('installer.update'))
|
||||
->action(function (Entry $component): true {
|
||||
$output = __('installer.starting_migration_process').PHP_EOL;
|
||||
$this->stream(content: PHP_EOL.__('installer.starting_migration_process').PHP_EOL, to: $this->stream);
|
||||
$this->stream(
|
||||
content: PHP_EOL.__('installer.starting_migration_process').PHP_EOL,
|
||||
to: $this->stream
|
||||
);
|
||||
|
||||
if (function_exists('proc_open')) {
|
||||
// Streaming the output of the command is only available with proc_open (relies on Symfony Process)
|
||||
app(MigrationService::class)
|
||||
->runAllMigrationsWithStreaming(function (string $buffer) use (&$output): void {
|
||||
$output .= $buffer;
|
||||
$this->stream(content: $buffer, to: $this->stream);
|
||||
});
|
||||
} else {
|
||||
$output .= app(MigrationService::class)
|
||||
->runAllMigrations();
|
||||
|
||||
$this->stream(content: $output, to: $this->stream);
|
||||
}
|
||||
|
||||
app(SeederService::class)->syncAllSeeds();
|
||||
|
||||
if (function_exists('proc_open')) {
|
||||
app(StreamedCommandsService::class)->streamArtisanCommand(['db:seed', '--force', '--class='.ShieldSeeder::class], function (string $buffer) use (&$output): void {
|
||||
app(MigrationService::class)
|
||||
->runAllMigrationsWithStreaming(function (string $buffer) use (&$output): void {
|
||||
$output .= $buffer;
|
||||
$this->stream(content: $buffer, to: $this->stream);
|
||||
});
|
||||
} else {
|
||||
Artisan::call('db:seed', ['--force' => true, '--class' => ShieldSeeder::class]);
|
||||
$buffer = Artisan::output();
|
||||
$output .= $buffer;
|
||||
$this->stream(content: $buffer, to: $this->stream);
|
||||
}
|
||||
|
||||
app(StreamedCommandsService::class)->streamArtisanCommand(
|
||||
['db:seed', '--force', '--class='.DatabaseSeeder::class],
|
||||
function (string $buffer) use (&$output): void {
|
||||
$output .= $buffer;
|
||||
$this->stream(content: $buffer, to: $this->stream);
|
||||
}
|
||||
);
|
||||
|
||||
$output .= __('installer.migrations_completed').PHP_EOL;
|
||||
$this->stream(content: __('installer.migrations_completed').PHP_EOL, to: $this->stream);
|
||||
|
||||
// Let's generate a new key if the app is still using the one from the .env.example
|
||||
if (config('app.key') === 'base64:1IcdcyMVAztKFFiqfJOX5w6FkOb9ONnjCA3bdxNbtQ4=' || config('app.key') === 'base64:zdgcDqu9PM8uGWCtMxd74ZqdGJIrnw812oRMmwDF6KY=') {
|
||||
$output .= __('installer.app_key_warning').' php artisan key:generate --force'.PHP_EOL;
|
||||
$this->stream(content: __('installer.app_key_warning').' php artisan key:generate --force'.PHP_EOL, to: $this->stream);
|
||||
}
|
||||
$this->stream(
|
||||
content: __('installer.migrations_completed').PHP_EOL,
|
||||
to: $this->stream
|
||||
);
|
||||
|
||||
$component->state(fn (): string => $output);
|
||||
|
||||
@ -263,6 +255,8 @@ class Installer extends Page
|
||||
|
||||
/**
|
||||
* Called when the form is filed
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function save(): void
|
||||
{
|
||||
@ -270,6 +264,20 @@ class Installer extends Page
|
||||
$this->airlineAndUserSetup();
|
||||
|
||||
flash()->success(__('installer.install_completed'));
|
||||
|
||||
// Log them in - attempt only wants these properties
|
||||
$user = [
|
||||
'email' => $this->user['email'],
|
||||
'password' => $this->user['password'],
|
||||
];
|
||||
|
||||
if (Auth::attempt($user)) {
|
||||
request()->session()->regenerate();
|
||||
$this->redirect('/admin');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->redirect('/login');
|
||||
}
|
||||
|
||||
@ -282,10 +290,11 @@ class Installer extends Page
|
||||
->schema([
|
||||
TextEntry::make('info')
|
||||
->label(__('installer.important'))
|
||||
->hintAction(Action::make('openDocs')
|
||||
->label(__('common.see_the_docs'))
|
||||
->url(docs_link('installation'))
|
||||
->openUrlInNewTab()
|
||||
->hintAction(
|
||||
Action::make('openDocs')
|
||||
->label(__('common.see_the_docs'))
|
||||
->url(docs_link('installation'))
|
||||
->openUrlInNewTab()
|
||||
)
|
||||
->state(fn (): string => __('installer.create_env')),
|
||||
|
||||
@ -294,8 +303,16 @@ class Installer extends Page
|
||||
TextEntry::make('php_passed')
|
||||
->hiddenLabel()
|
||||
->size('md')
|
||||
->state(fn (): string => $data['php']['passed'] && $data['extensionsPassed'] ? 'OK' : __('installer.failed'))
|
||||
->color(fn (): string => $data['php']['passed'] && $data['extensionsPassed'] ? 'success' : 'danger')
|
||||
->state(
|
||||
fn (
|
||||
): string => $data['php']['passed'] && $data['extensionsPassed'] ? 'OK' : __(
|
||||
'installer.failed'
|
||||
)
|
||||
)
|
||||
->color(
|
||||
fn (
|
||||
): string => $data['php']['passed'] && $data['extensionsPassed'] ? 'success' : 'danger'
|
||||
)
|
||||
->badge(),
|
||||
])
|
||||
->schema([
|
||||
@ -319,8 +336,15 @@ class Installer extends Page
|
||||
TextEntry::make('directory_passed')
|
||||
->hiddenLabel()
|
||||
->size('md')
|
||||
->state(fn (): string => $data['directoriesPassed'] ? 'OK' : __('installer.failed'))
|
||||
->color(fn (): string => $data['directoriesPassed'] ? 'success' : 'danger')
|
||||
->state(
|
||||
fn (): string => $data['directoriesPassed'] ? 'OK' : __(
|
||||
'installer.failed'
|
||||
)
|
||||
)
|
||||
->color(
|
||||
fn (
|
||||
): string => $data['directoriesPassed'] ? 'success' : 'danger'
|
||||
)
|
||||
->badge(),
|
||||
])
|
||||
->description(__('installer.directory_permissions_description'))
|
||||
@ -338,15 +362,21 @@ class Installer extends Page
|
||||
TextEntry::make('database_passed')
|
||||
->hiddenLabel()
|
||||
->size('md')
|
||||
->state(fn (): string => $data['db']['passed'] ? 'OK' : __('installer.failed'))
|
||||
->color(fn (): string => $data['db']['passed'] ? 'success' : 'danger')
|
||||
->state(
|
||||
fn (): string => $data['db']['passed'] ? 'OK' : __(
|
||||
'installer.failed'
|
||||
)
|
||||
)
|
||||
->color(fn (): string => $data['db']['passed'] ? 'success' : 'danger'
|
||||
)
|
||||
->badge(),
|
||||
])
|
||||
->schema([
|
||||
TextEntry::make('db_connection')
|
||||
->inlineLabel($data['db']['passed'])
|
||||
->label(__('installer.database_connection'))
|
||||
->color(fn (): string => $data['db']['passed'] ? 'success' : 'danger')
|
||||
->color(fn (): string => $data['db']['passed'] ? 'success' : 'danger'
|
||||
)
|
||||
->alignEnd($data['db']['passed'])
|
||||
->badge($data['db']['passed'])
|
||||
->state(fn () => $data['db']['msg']),
|
||||
@ -362,7 +392,6 @@ class Installer extends Page
|
||||
throw new Halt();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private function getMigrationStep(): Step
|
||||
@ -380,7 +409,16 @@ class Installer extends Page
|
||||
->afterValidation(function (): void {
|
||||
if (count(app(MigrationService::class)->migrationsAvailable()) > 0) {
|
||||
Notification::make()
|
||||
->title(__('installer.migrations_not_completed', ['count' => count(app(MigrationService::class)->migrationsAvailable())]))
|
||||
->title(
|
||||
__(
|
||||
'installer.migrations_not_completed',
|
||||
[
|
||||
'count' => count(
|
||||
app(MigrationService::class)->migrationsAvailable()
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
@ -394,14 +432,8 @@ class Installer extends Page
|
||||
return
|
||||
Step::make(__('installer.user_and_airline_setup'))
|
||||
->schema([
|
||||
Section::make(__('filament.airline_informations'))
|
||||
Section::make(__('filament.airline_information'))
|
||||
->statePath('user')
|
||||
->headerActions([
|
||||
Action::make('test')
|
||||
->label(__('installer.legacy_importer'))
|
||||
->openUrlInNewTab()
|
||||
->url(docs_link('importing_legacy')),
|
||||
])
|
||||
->schema([
|
||||
TextInput::make('airline_icao')
|
||||
->length(3)
|
||||
@ -424,7 +456,7 @@ class Installer extends Page
|
||||
])
|
||||
->columns(),
|
||||
|
||||
Section::make(__('installer.super_admin_informations'))
|
||||
Section::make(__('installer.super_admin_information'))
|
||||
->statePath('user')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
@ -454,6 +486,16 @@ class Installer extends Page
|
||||
]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getHeader(): ?View
|
||||
{
|
||||
return view('filament.system.hero', [
|
||||
'eyebrow' => __('installer.eyebrow'),
|
||||
'title' => __('installer.hero_title'),
|
||||
'subtitle' => __('installer.hero_subtitle'),
|
||||
]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getTitle(): string
|
||||
{
|
||||
|
||||
@ -86,7 +86,7 @@ class AirlineFinanceChart extends ChartWidget
|
||||
#[\Override]
|
||||
public static function canView(): bool
|
||||
{
|
||||
// Display if the page is finance or /livewire/update from finance
|
||||
// Display if the page is finance or a /livewire-{hash}/update request from finance
|
||||
if (request()->url() === Finances::getUrl()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -91,7 +91,7 @@ class AirlineFinanceTable extends TableWidget
|
||||
#[\Override]
|
||||
public static function canView(): bool
|
||||
{
|
||||
// Display if the page is finance or /livewire/update from finance
|
||||
// Display if the page is finance or a /livewire-{hash}/update request from finance
|
||||
if (request()->url() === Finances::getUrl()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1,117 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Events\NewsAdded;
|
||||
use App\Events\NewsUpdated;
|
||||
use App\Models\News as NewsModel;
|
||||
use BezhanSalleh\FilamentShield\Traits\HasWidgetShield;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Support\Enums\FontWeight;
|
||||
use Filament\Support\Enums\Size;
|
||||
use Filament\Support\Enums\TextSize;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\Layout\Stack;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget as BaseWidget;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class News extends BaseWidget
|
||||
{
|
||||
use HasWidgetShield;
|
||||
|
||||
protected static ?string $pollingInterval = null;
|
||||
|
||||
protected static ?int $sort = 1;
|
||||
|
||||
private function formContent(): array
|
||||
{
|
||||
return [
|
||||
TextInput::make('subject')
|
||||
->label(__('filament.news_subject'))
|
||||
->string()
|
||||
->required(),
|
||||
|
||||
RichEditor::make('body')
|
||||
->label(__('filament.news_body'))
|
||||
->required(),
|
||||
|
||||
Toggle::make('send_notifications')
|
||||
->label(__('filament.news_send_notifications'))
|
||||
->onColor('success')
|
||||
->onIcon(Heroicon::CheckCircle)
|
||||
->offColor('danger')
|
||||
->offIcon(Heroicon::XCircle),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(
|
||||
NewsModel::orderBy('created_at', 'desc')
|
||||
)
|
||||
->heading(__('widgets.latestnews.news'))
|
||||
->modelLabel(__('widgets.latestnews.news'))
|
||||
->paginated([1, 2, 5])
|
||||
->defaultPaginationPageOption(2)
|
||||
->columns([
|
||||
Stack::make([
|
||||
TextColumn::make('subject')
|
||||
->size(TextSize::Large)
|
||||
->weight(FontWeight::Bold),
|
||||
|
||||
TextColumn::make('body')
|
||||
->color('gray')
|
||||
->html(),
|
||||
|
||||
TextColumn::make('user.name')
|
||||
->formatStateUsing(fn (NewsModel $record): string => $record->user->name.' - '.$record->created_at->diffForHumans())
|
||||
->alignEnd(),
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
ActionGroup::make([
|
||||
EditAction::make()
|
||||
->schema($this->formContent())
|
||||
->mutateDataUsing(function (array $data): array {
|
||||
$data['user_id'] = Auth::id();
|
||||
|
||||
return $data;
|
||||
})
|
||||
->after(function (array $data, NewsModel $record): void {
|
||||
if (get_truth_state($data['send_notifications'])) {
|
||||
event(new NewsUpdated($record));
|
||||
}
|
||||
}),
|
||||
|
||||
DeleteAction::make(),
|
||||
]),
|
||||
])
|
||||
->headerActions([
|
||||
CreateAction::make('create')
|
||||
->icon(Heroicon::OutlinedPlusCircle)
|
||||
->size(Size::Small)
|
||||
->model(NewsModel::class)
|
||||
->schema($this->formContent())
|
||||
->mutateDataUsing(function (array $data): array {
|
||||
$data['user_id'] = Auth::id();
|
||||
|
||||
return $data;
|
||||
})
|
||||
->after(function (array $data, NewsModel $record): void {
|
||||
if (get_truth_state($data['send_notifications'])) {
|
||||
event(new NewsAdded($record));
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,9 @@ use App\Filament\System\Installer;
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Check the app.key to see whether we're installed or not
|
||||
@ -21,17 +23,20 @@ use Illuminate\Support\Facades\Schema;
|
||||
*/
|
||||
class InstalledCheck implements Middleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$key = config('app.key');
|
||||
|
||||
// If we're in the installer, skip this
|
||||
// Also skip if this is a livewire update (might be called from the system)
|
||||
if ($request->is('system*') || request()->is('livewire/update')) {
|
||||
if ($request->is('system*') || request()->is('livewire-*/update')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (empty($key) || $key === 'base64:zdgcDqu9PM8uGWCtMxd74ZqdGJIrnw812oRMmwDF6KY=' || !Schema::hasTable('users') || User::count() === 0) {
|
||||
try {
|
||||
DB::connection()->getPdo();
|
||||
if (!Schema::hasTable('users') || User::count() === 0) {
|
||||
return redirect('/system/install');
|
||||
}
|
||||
} catch (\Exception) {
|
||||
return redirect('/system/install');
|
||||
}
|
||||
|
||||
|
||||
78
app/Livewire/Filament/ClearCaches.php
Normal file
78
app/Livewire/Filament/ClearCaches.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Filament;
|
||||
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\View\View;
|
||||
use Livewire\Component;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Throwable;
|
||||
|
||||
final class ClearCaches extends Component
|
||||
{
|
||||
/**
|
||||
* Clear all application caches by running `optimize:clear` (plus
|
||||
* supporting cache:clear and filament:optimize-clear, and removing
|
||||
* the cached themes/modules files).
|
||||
*
|
||||
* Mirrors the logic in App\Filament\Pages\Maintenance::clearCache so
|
||||
* the topbar shortcut and the maintenance page behave identically.
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
try {
|
||||
$module_cache_files = base_path().'/bootstrap/cache/*_module.php';
|
||||
foreach (File::glob($module_cache_files) as $file) {
|
||||
$deleted = File::delete($file) ? 'Module cache file deleted' : 'Module cache file not found!';
|
||||
Log::debug($deleted.' | '.$file);
|
||||
}
|
||||
|
||||
$calls = [
|
||||
'cache:clear',
|
||||
'optimize:clear',
|
||||
'filament:optimize-clear',
|
||||
];
|
||||
|
||||
foreach ($calls as $call) {
|
||||
Process::env(['APP_RUNNING_IN_CONSOLE' => true])
|
||||
->run([$this->getPhpBinary(), base_path('artisan'), $call])->throw();
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(__('filament.maintenance_cache_cleared'))
|
||||
->success()
|
||||
->send();
|
||||
} catch (Throwable $throwable) {
|
||||
Log::error('ClearCaches plugin failed', ['error' => $throwable->getMessage()]);
|
||||
|
||||
Notification::make()
|
||||
->title(__('filament.maintenance_cache_clear_failed'))
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.filament.clear-caches');
|
||||
}
|
||||
|
||||
private function getPhpBinary(): string
|
||||
{
|
||||
$finder = new PhpExecutableFinder();
|
||||
$php_path = $finder->find(false);
|
||||
$php = str_replace('-fpm', '', (string) $php_path);
|
||||
|
||||
if (str_contains($php, '-cgi')) {
|
||||
$php .= ' -d register_argc_argv=On';
|
||||
}
|
||||
|
||||
return $php;
|
||||
}
|
||||
}
|
||||
72
app/Livewire/Filament/PirepCommentThread.php
Normal file
72
app/Livewire/Filament/PirepCommentThread.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Filament;
|
||||
|
||||
use App\Models\Pirep;
|
||||
use App\Models\PirepComment;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Chat-style PIREP comment thread.
|
||||
*
|
||||
* Renders the pilot note + existing comments as a read-only chat feed, with
|
||||
* a compact compose box at the bottom for posting a new comment. Authorization
|
||||
* is delegated to PirepCommentPolicy so the same `update:pirep` gate that
|
||||
* Filament uses for the resource also gates comment creation here.
|
||||
*/
|
||||
final class PirepCommentThread extends Component
|
||||
{
|
||||
public Pirep $record;
|
||||
|
||||
#[Validate('required|string|max:5000')]
|
||||
public string $newComment = '';
|
||||
|
||||
public function mount(Pirep $record): void
|
||||
{
|
||||
$this->record = $record;
|
||||
$this->record->loadMissing('comments.user', 'user');
|
||||
}
|
||||
|
||||
public function addComment(): void
|
||||
{
|
||||
if (!Gate::allows('create', PirepComment::class)) {
|
||||
Notification::make()
|
||||
->title(__('common.not_authorized'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
|
||||
PirepComment::create([
|
||||
'pirep_id' => $this->record->id,
|
||||
'user_id' => auth()->id(),
|
||||
'comment' => $this->newComment,
|
||||
]);
|
||||
|
||||
$this->newComment = '';
|
||||
|
||||
// Refresh comments so the new row renders without a full page reload.
|
||||
$this->record->load('comments.user');
|
||||
|
||||
Notification::make()
|
||||
->title(trans_choice('pireps.comment', 1).' '.__('common.added'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.filament.pirep-comment-thread', [
|
||||
'canComment' => Gate::allows('create', PirepComment::class),
|
||||
]);
|
||||
}
|
||||
}
|
||||
74
app/Policies/Filament/NewsPolicy.php
Normal file
74
app/Policies/Filament/NewsPolicy.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies\Filament;
|
||||
|
||||
use App\Models\News;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Foundation\Auth\User as AuthUser;
|
||||
|
||||
class NewsPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('view-any:news');
|
||||
}
|
||||
|
||||
public function view(AuthUser $authUser, News $news): bool
|
||||
{
|
||||
return $authUser->can('view:news');
|
||||
}
|
||||
|
||||
public function create(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('create:news');
|
||||
}
|
||||
|
||||
public function update(AuthUser $authUser, News $news): bool
|
||||
{
|
||||
return $authUser->can('update:news');
|
||||
}
|
||||
|
||||
public function delete(AuthUser $authUser, News $news): bool
|
||||
{
|
||||
return $authUser->can('delete:news');
|
||||
}
|
||||
|
||||
public function deleteAny(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('delete-any:news');
|
||||
}
|
||||
|
||||
public function restore(AuthUser $authUser, News $news): bool
|
||||
{
|
||||
return $authUser->can('restore:news');
|
||||
}
|
||||
|
||||
public function forceDelete(AuthUser $authUser, News $news): bool
|
||||
{
|
||||
return $authUser->can('force-delete:news');
|
||||
}
|
||||
|
||||
public function forceDeleteAny(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('force-delete-any:news');
|
||||
}
|
||||
|
||||
public function restoreAny(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('restore-any:news');
|
||||
}
|
||||
|
||||
public function replicate(AuthUser $authUser, News $news): bool
|
||||
{
|
||||
return $authUser->can('replicate:news');
|
||||
}
|
||||
|
||||
public function reorder(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('reorder:news');
|
||||
}
|
||||
}
|
||||
61
app/Policies/Filament/PirepCommentPolicy.php
Normal file
61
app/Policies/Filament/PirepCommentPolicy.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies\Filament;
|
||||
|
||||
use App\Models\PirepComment;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Foundation\Auth\User as AuthUser;
|
||||
|
||||
/**
|
||||
* Comments are a sub-capability of PIREP management — anyone who can view a
|
||||
* PIREP can read its comments, anyone who can update a PIREP can comment on
|
||||
* it, and anyone who can delete a PIREP can prune comments. Reusing the
|
||||
* `pirep` permissions avoids permission-table sprawl and keeps the mental
|
||||
* model consistent (no separate "manage comments" role to grant).
|
||||
*/
|
||||
class PirepCommentPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('view-any:pirep');
|
||||
}
|
||||
|
||||
public function view(AuthUser $authUser, PirepComment $pirepComment): bool
|
||||
{
|
||||
return $authUser->can('view:pirep');
|
||||
}
|
||||
|
||||
public function create(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('update:pirep');
|
||||
}
|
||||
|
||||
public function update(AuthUser $authUser, PirepComment $pirepComment): bool
|
||||
{
|
||||
return $authUser->can('update:pirep');
|
||||
}
|
||||
|
||||
public function delete(AuthUser $authUser, PirepComment $pirepComment): bool
|
||||
{
|
||||
return $authUser->can('update:pirep');
|
||||
}
|
||||
|
||||
public function deleteAny(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('update:pirep');
|
||||
}
|
||||
|
||||
public function restore(AuthUser $authUser, PirepComment $pirepComment): bool
|
||||
{
|
||||
return $authUser->can('update:pirep');
|
||||
}
|
||||
|
||||
public function forceDelete(AuthUser $authUser, PirepComment $pirepComment): bool
|
||||
{
|
||||
return $authUser->can('delete:pirep');
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,8 @@ use App\Services\ModuleService;
|
||||
use App\Support\ThemeViewFinder;
|
||||
use App\Support\Units\Time;
|
||||
use Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider;
|
||||
use Filament\Support\Facades\FilamentView;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Igaster\LaravelTheme\Facades\Theme;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -61,6 +63,22 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
activity()->disableLogging();
|
||||
|
||||
/**
|
||||
* Inject the extra display + monospace fonts used by the docs design
|
||||
* (Encode Sans for headings, Geist Mono + JetBrains Mono for code).
|
||||
* Served via Bunny Fonts (GDPR-compliant Google Fonts mirror — same
|
||||
* family names, same files, no Google CDN call). The body font (Geist)
|
||||
* is loaded by each panel via ->font('Geist'), which also writes
|
||||
* Filament's --font-family CSS variable. Family + weight list mirrors
|
||||
* docs/src/css/custom.css.
|
||||
*/
|
||||
FilamentView::registerRenderHook(
|
||||
PanelsRenderHook::HEAD_END,
|
||||
static fn (): string => <<<'HTML'
|
||||
<link rel="stylesheet" href="https://fonts.bunny.net/css?family=encode-sans:500,600,700|geist-mono:400,500|jetbrains-mono:400,500,600&display=swap">
|
||||
HTML,
|
||||
);
|
||||
|
||||
Notification::extend('discord_webhook', fn ($app) => app(DiscordWebhook::class));
|
||||
|
||||
/**
|
||||
|
||||
@ -4,8 +4,10 @@ namespace App\Providers\Filament;
|
||||
|
||||
use App\Enums\NavigationGroup as EnumsNavigationGroup;
|
||||
use App\Filament\Pages\Backups;
|
||||
use App\Filament\Plugins\ClearCachesPlugin;
|
||||
use App\Filament\Plugins\LanguageSwitcherPlugin;
|
||||
use App\Filament\Plugins\ModuleLinksPlugin;
|
||||
use App\Filament\Plugins\SidebarCollapseTogglePlugin;
|
||||
use BezhanSalleh\FilamentShield\FilamentShieldPlugin;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@ -13,11 +15,15 @@ use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Assets\AlpineComponent;
|
||||
use Filament\Support\Assets\Css;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Support\Facades\FilamentView;
|
||||
use Filament\Support\Facades\FilamentAsset;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Filament\Widgets\FilamentInfoWidget;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
@ -40,6 +46,9 @@ class AdminPanelProvider extends PanelProvider
|
||||
->colors([
|
||||
'primary' => Color::generatePalette('#067ec1'),
|
||||
])
|
||||
->assets([
|
||||
Css::make('leaflet', 'https://unpkg.com/leaflet@1.7.1/dist/leaflet.css'),
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
|
||||
->pages([])
|
||||
@ -63,10 +72,11 @@ class AdminPanelProvider extends PanelProvider
|
||||
Authenticate::class,
|
||||
])
|
||||
->sidebarCollapsibleOnDesktop()
|
||||
->sidebarWidth('14.5rem')
|
||||
->navigationGroups([
|
||||
EnumsNavigationGroup::Config->name,
|
||||
EnumsNavigationGroup::Operations->name,
|
||||
EnumsNavigationGroup::Modules->name,
|
||||
EnumsNavigationGroup::Config->name,
|
||||
EnumsNavigationGroup::AddOns->name,
|
||||
EnumsNavigationGroup::Developers->name,
|
||||
])
|
||||
->navigationItems([
|
||||
@ -93,13 +103,28 @@ class AdminPanelProvider extends PanelProvider
|
||||
FilamentSpatieLaravelBackupPlugin::make()
|
||||
->usingPage(Backups::class),
|
||||
ModuleLinksPlugin::make(),
|
||||
ClearCachesPlugin::make(),
|
||||
LanguageSwitcherPlugin::make(),
|
||||
SidebarCollapseTogglePlugin::make(),
|
||||
])
|
||||
->bootUsing(function (): void {
|
||||
activity()->enableLogging();
|
||||
})
|
||||
->brandName('phpVMS')
|
||||
->favicon(public_asset('assets/img/favicon.png'))
|
||||
->brandName('phpvms')
|
||||
->brandLogo(fn (): Factory|\Illuminate\Contracts\View\View => view('filament.shared.brand'))
|
||||
->brandLogoHeight('3rem')
|
||||
->font('Geist')
|
||||
->favicon(asset('assets/img/favicon.png'))
|
||||
->renderHook(
|
||||
PanelsRenderHook::AUTH_LOGIN_FORM_BEFORE,
|
||||
fn (): string => view('filament.auth.login-hero')->render(),
|
||||
)
|
||||
// Inject vite this way - it might not exist when this is registered
|
||||
->renderHook(
|
||||
PanelsRenderHook::HEAD_END,
|
||||
fn (): string => Blade::render("@vite('resources/js/admin/app.js')"),
|
||||
)
|
||||
->breadcrumbs(false)
|
||||
->unsavedChangesAlerts()
|
||||
->spa(hasPrefetching: config('phpvms.use_prefetching_in_admin', false))
|
||||
->errorNotifications()
|
||||
@ -107,13 +132,32 @@ class AdminPanelProvider extends PanelProvider
|
||||
->viteTheme('resources/css/filament/admin/theme.css');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function register(): void
|
||||
public function boot(): void
|
||||
{
|
||||
parent::register();
|
||||
// Vite hot reloading (not needed in production)
|
||||
if (!app()->isProduction()) {
|
||||
FilamentView::registerRenderHook('panels::body.end', static fn (): string => Blade::render("@vite('resources/js/entrypoint.js')"));
|
||||
}
|
||||
// AlpineComponent assets are esbuild-built standalone files in
|
||||
// resources/js/dist/admin/components/ (see bin/build.js) and are
|
||||
// referenced via FilamentAsset::getAlpineComponentSrc() in blade.
|
||||
// These files do not go through Vite, so registering them at boot
|
||||
// is safe: no manifest lookup, no console crash on fresh checkout.
|
||||
FilamentAsset::register([
|
||||
AlpineComponent::make(
|
||||
'pirep-performance-chart',
|
||||
resource_path('js/dist/admin/components/pirep-performance-chart.js'),
|
||||
),
|
||||
AlpineComponent::make(
|
||||
'pirep-landing-analysis',
|
||||
resource_path('js/dist/admin/components/pirep-landing-analysis.js'),
|
||||
),
|
||||
]);
|
||||
|
||||
// Expose map-related config to JS (window.filamentData.maps).
|
||||
// The OpenAIP overlay needs an API key client-side — pulling from
|
||||
// config keeps it out of the bundled JS and lets each install
|
||||
// configure its own key in .env.
|
||||
FilamentAsset::registerScriptData([
|
||||
'maps' => [
|
||||
'openaip_api_key' => config('services.openaip.api_key'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,11 +6,13 @@ namespace App\Providers\Filament;
|
||||
|
||||
use App\Filament\Plugins\LanguageSwitcherPlugin;
|
||||
use App\Http\Middleware\SetActiveLanguage;
|
||||
use Filament\Enums\ThemeMode;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
@ -47,12 +49,17 @@ class SystemPanelProvider extends PanelProvider
|
||||
->plugins([
|
||||
LanguageSwitcherPlugin::make(),
|
||||
])
|
||||
->brandName('phpVMS')
|
||||
->favicon(public_asset('assets/img/favicon.png'))
|
||||
->defaultThemeMode(ThemeMode::Light)
|
||||
->brandName('phpvms')
|
||||
->font('Geist')
|
||||
->brandLogo(fn (): Factory|\Illuminate\Contracts\View\View => view('filament.shared.brand'))
|
||||
->brandLogoHeight('3rem')
|
||||
->favicon(asset('assets/img/favicon.png'))
|
||||
->viteTheme('resources/css/filament/admin/theme.css')
|
||||
->unsavedChangesAlerts()
|
||||
->navigation(false)
|
||||
->spa()
|
||||
->breadcrumbs(false)
|
||||
->errorNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,8 +9,9 @@ use App\Models\Setting;
|
||||
use App\Services\DatabaseService;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\Finder\SplFileInfo;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
use function trim;
|
||||
@ -21,13 +22,6 @@ class SeederService extends Service
|
||||
|
||||
private array $offsets = [];
|
||||
|
||||
// Map an environment to a seeder directory, if we want to share
|
||||
public static array $seedMapper = [
|
||||
'production' => 'prod',
|
||||
'dev' => 'local',
|
||||
'development' => 'local',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly DatabaseService $databaseSvc
|
||||
) {}
|
||||
@ -43,6 +37,8 @@ class SeederService extends Service
|
||||
/**
|
||||
* Syncronize all the seed files, run this after the migrations
|
||||
* and on first install.
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function syncAllSeeds(): void
|
||||
{
|
||||
@ -61,22 +57,18 @@ class SeederService extends Service
|
||||
{
|
||||
Log::info('Running seeder');
|
||||
$env = App::environment();
|
||||
if (array_key_exists($env, self::$seedMapper)) {
|
||||
$env = self::$seedMapper[$env];
|
||||
|
||||
$seedPath = database_path('seeders/'.$env);
|
||||
if (!File::isDirectory($seedPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Gather all of the files to seed
|
||||
collect()
|
||||
->concat(Storage::disk('seeds')->files($env))
|
||||
->map(fn (string $file): string => database_path('seeders/'.$file))
|
||||
->filter(function ($file): bool {
|
||||
$info = pathinfo($file);
|
||||
|
||||
return $info['extension'] === 'yml';
|
||||
})
|
||||
->each(function (string $file): void {
|
||||
Log::info('Seeding .'.$file);
|
||||
$this->databaseSvc->seedFromYamlFile($file);
|
||||
collect(File::allFiles($seedPath))
|
||||
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'yml')
|
||||
->each(function (SplFileInfo $file): void {
|
||||
$path = $file->getPathname();
|
||||
Log::info('Seeding '.$path);
|
||||
$this->databaseSvc->seedFromYamlFile($path);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
633
app/Services/Pirep/PerformanceChartService.php
Normal file
633
app/Services/Pirep/PerformanceChartService.php
Normal file
@ -0,0 +1,633 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Pirep;
|
||||
|
||||
use App\Enums\AcarsType;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Models\Acars;
|
||||
use App\Models\Pirep;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class PerformanceChartService
|
||||
{
|
||||
public const int MAX_POINTS = 500;
|
||||
|
||||
/**
|
||||
* Build the chart payload for a PIREP. Returns null when no ACARS samples
|
||||
* exist — the blade renders an empty stub instead of a chart container.
|
||||
*
|
||||
* @return array{
|
||||
* sample_count: int,
|
||||
* series: array<string, array<string, mixed>>,
|
||||
* phases: array<int, array{code: string, label: string, start: int, end: int}>,
|
||||
* meta: array<string, mixed>,
|
||||
* landing: array<string, mixed>|null,
|
||||
* summary: array{climb_seconds: int, cruise_seconds: int, descent_seconds: int, cruise_altitude: ?int},
|
||||
* }|null
|
||||
*/
|
||||
public function buildDatasets(Pirep $pirep): ?array
|
||||
{
|
||||
// Inline the Acars query rather than calling its `ofType` / `orderedByCreatedAt`
|
||||
// scopes — larastan does not forward `#[Scope]` attribute methods through
|
||||
// HasMany relation builders, so the scoped form trips a false-positive
|
||||
// method.notFound at PHPStan level 5 even though both scopes exist at runtime.
|
||||
$samples = $pirep->acars()
|
||||
->where('type', AcarsType::FLIGHT_PATH)
|
||||
->orderBy('created_at', 'asc')
|
||||
->get();
|
||||
|
||||
if ($samples->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reduced = $this->downsample($samples, self::MAX_POINTS);
|
||||
$phases = $this->detectPhases($pirep, $reduced);
|
||||
|
||||
return [
|
||||
'sample_count' => $samples->count(),
|
||||
'series' => [
|
||||
'altitude' => $this->altitudeSeries($reduced),
|
||||
'speed' => $this->speedSeries($reduced),
|
||||
'fuel' => $this->fuelSeries($reduced),
|
||||
'vs' => $this->vsSeries($reduced),
|
||||
],
|
||||
'phases' => $phases,
|
||||
'meta' => $this->buildMeta($reduced),
|
||||
'landing' => $this->buildLandingBlock($pirep),
|
||||
'summary' => $this->buildSummary($phases, $reduced),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact phase-timing summary rendered as four stat boxes beneath the
|
||||
* performance chart. Bucket per PirepStatus code:
|
||||
*
|
||||
* - climb : TAKEOFF, INIT_CLIM, AIRBORNE
|
||||
* - cruise : ENROUTE
|
||||
* - descent : APPROACH, APPROACH_ICAO, ON_FINAL, LANDING, EMERG_DESCENT
|
||||
*
|
||||
* Cruise altitude is the max altitude observed inside any cruise phase;
|
||||
* falls back to the overall max when no cruise phase was detected so a
|
||||
* VFR / short hop without classified cruise still shows a number.
|
||||
*
|
||||
* @param array<int, array{code: string, label: string, start: int, end: int}> $phases
|
||||
* @return array{climb_seconds: int, cruise_seconds: int, descent_seconds: int, cruise_altitude: ?int}
|
||||
*/
|
||||
private function buildSummary(array $phases, Collection $samples): array
|
||||
{
|
||||
$climbCodes = ['TOF', 'ICL', 'TKO'];
|
||||
$cruiseCodes = ['ENR'];
|
||||
$descentCodes = ['TEN', 'APR', 'FIN', 'LDG', 'EMG'];
|
||||
|
||||
$climb = 0;
|
||||
$cruise = 0;
|
||||
$descent = 0;
|
||||
$cruiseRanges = [];
|
||||
|
||||
foreach ($phases as $phase) {
|
||||
$duration = max(0, $phase['end'] - $phase['start']);
|
||||
|
||||
if (in_array($phase['code'], $climbCodes, true)) {
|
||||
$climb += $duration;
|
||||
} elseif (in_array($phase['code'], $cruiseCodes, true)) {
|
||||
$cruise += $duration;
|
||||
$cruiseRanges[] = [$phase['start'], $phase['end']];
|
||||
} elseif (in_array($phase['code'], $descentCodes, true)) {
|
||||
$descent += $duration;
|
||||
}
|
||||
}
|
||||
|
||||
$cruiseAltitude = $this->maxAltitudeInRanges($samples, $cruiseRanges);
|
||||
|
||||
if ($cruiseAltitude === null) {
|
||||
$alts = $samples
|
||||
->map(fn ($s): ?float => $s->altitude_msl !== null ? (float) $s->altitude_msl : null)
|
||||
->filter(fn (?float $v): bool => $v !== null)
|
||||
->all();
|
||||
|
||||
$cruiseAltitude = $alts === [] ? null : (int) max($alts);
|
||||
}
|
||||
|
||||
return [
|
||||
'climb_seconds' => $climb,
|
||||
'cruise_seconds' => $cruise,
|
||||
'descent_seconds' => $descent,
|
||||
'cruise_altitude' => $cruiseAltitude,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Peak altitude among samples whose timestamp falls inside any of the
|
||||
* supplied [start, end] ranges. Returns null when no sample falls in
|
||||
* range or all in-range samples are missing altitude.
|
||||
*
|
||||
* @param array<int, array{0: int, 1: int}> $ranges
|
||||
*/
|
||||
private function maxAltitudeInRanges(Collection $samples, array $ranges): ?int
|
||||
{
|
||||
if ($ranges === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$max = null;
|
||||
|
||||
foreach ($samples as $s) {
|
||||
if ($s->altitude_msl === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ts = $this->ts($s);
|
||||
foreach ($ranges as [$start, $end]) {
|
||||
if ($ts >= $start && $ts <= $end) {
|
||||
$alt = (float) $s->altitude_msl;
|
||||
if ($max === null || $alt > $max) {
|
||||
$max = $alt;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $max === null ? null : (int) $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull departure + arrival runway metrics and landing scorecard data
|
||||
* from the PIREP's custom field values. Returns null when nothing
|
||||
* usable is present.
|
||||
*
|
||||
* Field-name lookups are case-insensitive substring matches against
|
||||
* the names ACARS clients use today (e.g. "Departure Runway",
|
||||
* "Landing Rate"). Storing them in this service rather than a config
|
||||
* file keeps the mapping next to where it's consumed; if more clients
|
||||
* adopt different naming, this becomes the one place to extend.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function buildLandingBlock(Pirep $pirep): ?array
|
||||
{
|
||||
$fields = $pirep->field_values
|
||||
->mapWithKeys(fn ($f): array => [strtolower((string) $f->name) => $f->value]);
|
||||
|
||||
if ($fields->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$get = fn (string $needle): ?string => $fields
|
||||
->first(fn ($_v, $k): bool => str_contains($k, $needle));
|
||||
|
||||
$departure = [
|
||||
'runway' => $get('departure runway'),
|
||||
'heading_deviation' => $this->toFloat($get('departure heading deviation')),
|
||||
'centerline_offset' => $this->toFloat($get('departure centerline deviation')),
|
||||
];
|
||||
|
||||
$arrival = [
|
||||
'runway' => $get('arrival runway'),
|
||||
'heading_deviation' => $this->toFloat($get('arrival heading deviation')),
|
||||
'centerline_offset' => $this->toFloat($get('arrival centerline deviation')),
|
||||
'threshold_distance' => $this->toFloat($get('arrival threshold distance')),
|
||||
'threshold_crossing_alt' => $this->toFloat($get('arrival threshold crossing height')),
|
||||
];
|
||||
|
||||
// Landing scorecard — raw values exposed alongside normalized 0–100
|
||||
// scores (where 100 = ideal). The frontend polar chart consumes the
|
||||
// scores; the table beneath shows the raw values for context.
|
||||
$landingRate = $this->toFloat($get('landing rate'));
|
||||
$landingG = $this->toFloat($get('landing g-force'));
|
||||
$landingPitch = $this->toFloat($get('landing pitch'));
|
||||
$landingRoll = $this->toFloat($get('landing roll'));
|
||||
|
||||
$scorecard = [
|
||||
'rate' => ['value' => $landingRate, 'score' => $this->scoreLandingRate($landingRate)],
|
||||
'g_force' => ['value' => $landingG, 'score' => $this->scoreGForce($landingG)],
|
||||
'pitch' => ['value' => $landingPitch, 'score' => $this->scorePitch($landingPitch)],
|
||||
'roll' => ['value' => $landingRoll, 'score' => $this->scoreRoll($landingRoll)],
|
||||
'centerline' => ['value' => $arrival['centerline_offset'], 'score' => $this->scoreCenterline($arrival['centerline_offset'])],
|
||||
'heading' => ['value' => $arrival['heading_deviation'], 'score' => $this->scoreHeading($arrival['heading_deviation'])],
|
||||
];
|
||||
|
||||
return [
|
||||
'departure' => $departure,
|
||||
'arrival' => $arrival,
|
||||
'scorecard' => $scorecard,
|
||||
];
|
||||
}
|
||||
|
||||
private function toFloat(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score landing rate (fpm). Ideal touchdown: -100 to -300 fpm.
|
||||
* Smoother (less negative) is still good; harder degrades fast.
|
||||
* Returns 0–100.
|
||||
*/
|
||||
private function scoreLandingRate(?float $fpm): float
|
||||
{
|
||||
if ($fpm === null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$rate = abs($fpm);
|
||||
|
||||
return match (true) {
|
||||
$rate <= 200.0 => 100.0,
|
||||
$rate <= 400.0 => 100.0 - ($rate - 200.0) / 200.0 * 30.0, // 100 → 70
|
||||
$rate <= 600.0 => 70.0 - ($rate - 400.0) / 200.0 * 40.0, // 70 → 30
|
||||
$rate <= 1000.0 => 30.0 - ($rate - 600.0) / 400.0 * 30.0, // 30 → 0
|
||||
default => 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
/** G-force at touchdown. Ideal ≤ 1.2g; hard landing 1.5g+. */
|
||||
private function scoreGForce(?float $g): float
|
||||
{
|
||||
if ($g === null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$g <= 1.2 => 100.0,
|
||||
$g <= 1.5 => 100.0 - ($g - 1.2) / 0.3 * 40.0, // 100 → 60
|
||||
$g <= 2.0 => 60.0 - ($g - 1.5) / 0.5 * 60.0, // 60 → 0
|
||||
default => 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Landing pitch (degrees nose-up). Ideal 2–6° for transport jets. */
|
||||
private function scorePitch(?float $pitch): float
|
||||
{
|
||||
if ($pitch === null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$pitch >= 2.0 && $pitch <= 6.0 => 100.0,
|
||||
$pitch >= 0.0 && $pitch < 2.0 => 60.0 + $pitch / 2.0 * 40.0,
|
||||
$pitch > 6.0 && $pitch <= 10.0 => 100.0 - ($pitch - 6.0) / 4.0 * 70.0,
|
||||
default => 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Roll at touchdown (degrees). Ideal ≤ 1°; >5° is bad. */
|
||||
private function scoreRoll(?float $deg): float
|
||||
{
|
||||
if ($deg === null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$absDeg = abs($deg);
|
||||
|
||||
return match (true) {
|
||||
$absDeg <= 1.0 => 100.0,
|
||||
$absDeg <= 3.0 => 100.0 - ($absDeg - 1.0) / 2.0 * 40.0,
|
||||
$absDeg <= 5.0 => 60.0 - ($absDeg - 3.0) / 2.0 * 60.0,
|
||||
default => 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Centerline deviation (meters/feet, units TBD by client). Tight ≤3, OK ≤10, ugly >20. */
|
||||
private function scoreCenterline(?float $offset): float
|
||||
{
|
||||
if ($offset === null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$abs = abs($offset);
|
||||
|
||||
return match (true) {
|
||||
$abs <= 3.0 => 100.0,
|
||||
$abs <= 10.0 => 100.0 - ($abs - 3.0) / 7.0 * 30.0,
|
||||
$abs <= 20.0 => 70.0 - ($abs - 10.0) / 10.0 * 50.0,
|
||||
default => 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Heading deviation from runway (degrees). Crosswind landings ≤5° normal. */
|
||||
private function scoreHeading(?float $deg): float
|
||||
{
|
||||
if ($deg === null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$abs = abs($deg);
|
||||
|
||||
return match (true) {
|
||||
$abs <= 1.0 => 100.0,
|
||||
$abs <= 3.0 => 100.0 - ($abs - 1.0) / 2.0 * 30.0,
|
||||
$abs <= 5.0 => 70.0 - ($abs - 3.0) / 2.0 * 40.0,
|
||||
$abs <= 10.0 => 30.0 - ($abs - 5.0) / 5.0 * 30.0,
|
||||
default => 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
private function downsample(Collection $samples, int $maxPoints): Collection
|
||||
{
|
||||
$total = $samples->count();
|
||||
|
||||
if ($total <= $maxPoints) {
|
||||
return $samples->values();
|
||||
}
|
||||
|
||||
$step = (int) ceil($total / $maxPoints);
|
||||
$lastIndex = $total - 1;
|
||||
|
||||
// Keep every Nth sample AND always keep the final sample so the chart
|
||||
// shows touchdown / arrival even when (count - 1) is not divisible by
|
||||
// step. Index 0 is already preserved by the modulo (0 % step === 0).
|
||||
return $samples->values()
|
||||
->filter(fn ($_, int $i): bool => $i % $step === 0 || $i === $lastIndex)
|
||||
->values();
|
||||
}
|
||||
|
||||
/** @return array{data: array<int, array{0: int, 1: float|null}>, min: float, max: float, avg_cruise: float|null} */
|
||||
private function altitudeSeries(Collection $samples): array
|
||||
{
|
||||
$points = $samples->map(fn ($s): array => [$this->ts($s), $s->altitude_msl])->all();
|
||||
$alts = array_filter(array_column($points, 1), fn ($v): bool => $v !== null);
|
||||
|
||||
return [
|
||||
'data' => $points,
|
||||
'min' => $alts === [] ? 0.0 : (float) min($alts),
|
||||
'max' => $alts === [] ? 0.0 : (float) max($alts),
|
||||
'avg_cruise' => null, // populated when phase detection runs
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{gs: array<int, array{0: int, 1: int|null}>, ias: array<int, array{0: int, 1: int|null}>, gs_max: int} */
|
||||
private function speedSeries(Collection $samples): array
|
||||
{
|
||||
$gs = $samples->map(fn ($s): array => [$this->ts($s), $s->gs])->all();
|
||||
$ias = $samples->map(fn ($s): array => [$this->ts($s), $s->ias])->all();
|
||||
|
||||
return [
|
||||
'gs' => $gs,
|
||||
'ias' => $ias,
|
||||
'gs_max' => (int) max(0, ...array_filter(array_column($gs, 1))),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{data: array<int, array{0: int, 1: float|null}>, flow_avg: float|null} */
|
||||
private function fuelSeries(Collection $samples): array
|
||||
{
|
||||
// Preserve legitimate zero-fuel samples — `$s->fuel ? ... : null` would drop them.
|
||||
$points = $samples->map(fn ($s): array => [$this->ts($s), $s->fuel->toUnit('lbs') !== null ? (float) $s->fuel->toUnit('lbs') : null])->all();
|
||||
$flows = array_filter($samples->pluck('fuel_flow')->all(), fn ($v): bool => $v !== null);
|
||||
|
||||
return [
|
||||
'data' => $points,
|
||||
'flow_avg' => $flows === [] ? null : array_sum($flows) / count($flows),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{data: array<int, array{0: int, 1: float|null}>, max_climb: float, max_descent: float} */
|
||||
private function vsSeries(Collection $samples): array
|
||||
{
|
||||
$points = $samples->map(fn ($s): array => [$this->ts($s), $s->vs])->all();
|
||||
$vs = array_filter(array_column($points, 1), fn ($v): bool => $v !== null);
|
||||
|
||||
return [
|
||||
'data' => $points,
|
||||
'max_climb' => $vs === [] ? 0.0 : (float) max($vs),
|
||||
'max_descent' => $vs === [] ? 0.0 : (float) min($vs),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log-substring → PirepStatus marker table. Ordered by typical flight
|
||||
* sequence; substrings matched case-insensitively against the first
|
||||
* occurrence of each row in the LOG stream (with "flaps set to up"
|
||||
* gated to fire only after takeoff — pre-takeoff flap retract and
|
||||
* post-landing flap stow share the same string).
|
||||
*
|
||||
* @var array<int, array{needle: string, status: PirepStatus, after_takeoff: bool}>
|
||||
*/
|
||||
private const array LOG_MARKERS = [
|
||||
['needle' => 'started boarding', 'status' => PirepStatus::BOARDING, 'after_takeoff' => false],
|
||||
['needle' => 'started pushback', 'status' => PirepStatus::PUSHBACK_TOW, 'after_takeoff' => false],
|
||||
['needle' => 'started taxi out', 'status' => PirepStatus::TAXI, 'after_takeoff' => false],
|
||||
['needle' => 'started takeoff', 'status' => PirepStatus::TAKEOFF, 'after_takeoff' => false],
|
||||
['needle' => 'flaps set to up', 'status' => PirepStatus::ENROUTE, 'after_takeoff' => true],
|
||||
['needle' => 'on approach', 'status' => PirepStatus::APPROACH_ICAO, 'after_takeoff' => true],
|
||||
['needle' => 'on final approach', 'status' => PirepStatus::ON_FINAL, 'after_takeoff' => true],
|
||||
['needle' => 'landing rate', 'status' => PirepStatus::LANDING, 'after_takeoff' => true],
|
||||
['needle' => 'blocks on time', 'status' => PirepStatus::ON_BLOCK, 'after_takeoff' => true],
|
||||
];
|
||||
|
||||
/**
|
||||
* Phase detection strategy:
|
||||
*
|
||||
* 1. If FLIGHT_PATH samples carry real per-sample status (anything other
|
||||
* than the default 'SCH'), emit one phase per contiguous status run.
|
||||
* 2. Otherwise scan the LOG rows for known marker substrings and derive
|
||||
* phases from marker timestamps.
|
||||
* 3. If neither produces phases (no logs either), fall back to a VS-
|
||||
* derived heuristic so the chart still gets some shading.
|
||||
*
|
||||
* @return array<int, array{code: string, label: string, start: int, end: int}>
|
||||
*/
|
||||
private function detectPhases(Pirep $pirep, Collection $samples): array
|
||||
{
|
||||
$hasRealStatus = $samples->contains(fn ($s): bool => $s->status !== null && $s->status !== 'SCH');
|
||||
|
||||
if ($hasRealStatus) {
|
||||
return $this->collapseToPhases(
|
||||
$samples,
|
||||
fn ($s): string => (string) ($s->status ?? 'SCH'),
|
||||
);
|
||||
}
|
||||
|
||||
$fromLogs = $this->detectPhasesFromLogs($pirep, $samples);
|
||||
if ($fromLogs !== []) {
|
||||
return $fromLogs;
|
||||
}
|
||||
|
||||
return $this->detectPhasesFromVs($samples);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan ACARS LOG rows for known marker substrings (boarding / pushback /
|
||||
* taxi / takeoff / enroute / approach / final / landing / on-block) and
|
||||
* emit one phase per consecutive marker pair. Final phase end-anchored
|
||||
* to the last flight-path sample.
|
||||
*
|
||||
* Returns an empty array when the LOG stream produces no markers — the
|
||||
* caller then falls back to the VS heuristic.
|
||||
*
|
||||
* @return array<int, array{code: string, label: string, start: int, end: int}>
|
||||
*/
|
||||
private function detectPhasesFromLogs(Pirep $pirep, Collection $samples): array
|
||||
{
|
||||
// Inline rather than using the `acars()` relation (which prescopes to
|
||||
// FLIGHT_PATH) or `acars_logs()` (which orders desc) — we need LOG
|
||||
// rows ordered ascending so the first-match-wins marker scan picks up
|
||||
// markers in flight-time order.
|
||||
$logs = $pirep->hasMany(Acars::class, 'pirep_id')
|
||||
->where('type', AcarsType::LOG)
|
||||
->whereNotNull('log')
|
||||
->orderBy('created_at', 'asc')
|
||||
->get();
|
||||
|
||||
if ($logs->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$takeoffSeen = false;
|
||||
$matched = [];
|
||||
|
||||
foreach ($logs as $logRow) {
|
||||
$haystack = strtolower((string) $logRow->log);
|
||||
$ts = $this->ts($logRow);
|
||||
|
||||
foreach (self::LOG_MARKERS as $i => $marker) {
|
||||
if (isset($matched[$i])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($marker['after_takeoff'] && !$takeoffSeen) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!str_contains($haystack, $marker['needle'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched[$i] = ['status' => $marker['status'], 'ts' => $ts];
|
||||
|
||||
if ($marker['status'] === PirepStatus::TAKEOFF) {
|
||||
$takeoffSeen = true;
|
||||
}
|
||||
|
||||
// Each log row maps to at most one marker — first match wins.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matched === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Preserve LOG_MARKERS table order rather than match-discovery order.
|
||||
ksort($matched);
|
||||
$ordered = array_values($matched);
|
||||
|
||||
$phases = [];
|
||||
$endAnchor = $this->ts($samples->last());
|
||||
|
||||
foreach ($ordered as $idx => $entry) {
|
||||
$next = $ordered[$idx + 1] ?? null;
|
||||
|
||||
$phases[] = [
|
||||
'code' => $entry['status']->value,
|
||||
'label' => $entry['status']->value,
|
||||
'start' => $entry['ts'],
|
||||
'end' => $next['ts'] ?? $endAnchor,
|
||||
];
|
||||
}
|
||||
|
||||
return $phases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort heuristic when neither per-sample status nor LOG markers
|
||||
* are available. Cruise threshold: |vs| < 200 fpm.
|
||||
*
|
||||
* @return array<int, array{code: string, label: string, start: int, end: int}>
|
||||
*/
|
||||
private function detectPhasesFromVs(Collection $samples): array
|
||||
{
|
||||
return $this->collapseToPhases(
|
||||
$samples,
|
||||
fn ($s): string => match (true) {
|
||||
(float) ($s->vs ?? 0) > 200 => PirepStatus::INIT_CLIM->value,
|
||||
(float) ($s->vs ?? 0) < -200 => PirepStatus::APPROACH_ICAO->value,
|
||||
default => PirepStatus::ENROUTE->value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the sample collection, group contiguous runs that share the same
|
||||
* phase code (resolved by `$codeFor`), and emit one entry per run with
|
||||
* its translated PirepStatus label.
|
||||
*
|
||||
* @param callable(Acars): string $codeFor
|
||||
* @return array<int, array{code: string, label: string, start: int, end: int}>
|
||||
*/
|
||||
private function collapseToPhases(Collection $samples, callable $codeFor): array
|
||||
{
|
||||
$phases = [];
|
||||
$first = $samples->first();
|
||||
$currentCode = $codeFor($first);
|
||||
$currentStart = $this->ts($first);
|
||||
|
||||
foreach ($samples as $s) {
|
||||
$code = $codeFor($s);
|
||||
|
||||
if ($code !== $currentCode) {
|
||||
$phases[] = [
|
||||
'code' => $currentCode,
|
||||
'label' => $this->phaseLabel($currentCode),
|
||||
'start' => $currentStart,
|
||||
'end' => $this->ts($s),
|
||||
];
|
||||
$currentCode = $code;
|
||||
$currentStart = $this->ts($s);
|
||||
}
|
||||
}
|
||||
|
||||
$phases[] = [
|
||||
'code' => $currentCode,
|
||||
'label' => $this->phaseLabel($currentCode),
|
||||
'start' => $currentStart,
|
||||
'end' => $this->ts($samples->last()),
|
||||
];
|
||||
|
||||
return $phases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label = the PirepStatus 3-letter code itself (e.g. 'TXI', 'ENR').
|
||||
* Chart corner real estate is cramped and the codes are unambiguous
|
||||
* to anyone reading flight data. Unknown codes pass through as-is.
|
||||
*/
|
||||
private function phaseLabel(string $code): string
|
||||
{
|
||||
return $code;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function buildMeta(Collection $samples): array
|
||||
{
|
||||
$start = $this->ts($samples->first());
|
||||
$end = $this->ts($samples->last());
|
||||
|
||||
return [
|
||||
'duration_seconds' => $end - $start,
|
||||
'first_sample_ts' => $start,
|
||||
'last_sample_ts' => $end,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix timestamp from an Acars row's created_at, in seconds. Returns 0
|
||||
* when created_at is null (Acars::$created_at is documented Carbon|null
|
||||
* and partial imports can leave it unset). The explicit isset() guard
|
||||
* sidesteps a larastan false positive — its model resolver narrows the
|
||||
* type to non-null Carbon, so `?->` reads as nullsafe.neverNull at
|
||||
* level 5 even though the property is genuinely nullable at runtime.
|
||||
*/
|
||||
private function ts(mixed $sample): int
|
||||
{
|
||||
$createdAt = $sample->created_at;
|
||||
|
||||
return $createdAt instanceof \DateTimeInterface ? $createdAt->getTimestamp() : 0;
|
||||
}
|
||||
}
|
||||
@ -29,14 +29,27 @@ class GeoJson
|
||||
protected $point_coords = [];
|
||||
|
||||
/**
|
||||
* Add a point to the line + point collections. Silently drops rows whose
|
||||
* lat/lon cannot be coerced into floats (null, empty string, garbage)
|
||||
* so a single malformed ACARS sample does not break the entire map for
|
||||
* a PIREP. The geojson lib's Point constructor throws
|
||||
* "Position elements must be integers or floats" otherwise.
|
||||
*
|
||||
* @param array $attrs Attributes of the Feature
|
||||
*/
|
||||
public function addPoint($lat, $lon, array $attrs): void
|
||||
{
|
||||
if (!is_numeric($lat) || !is_numeric($lon)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lat = (float) $lat;
|
||||
$lon = (float) $lon;
|
||||
|
||||
$point = [$lon, $lat];
|
||||
$this->line_coords[] = [$lon, $lat];
|
||||
|
||||
if (array_key_exists('alt', $attrs)) {
|
||||
if (array_key_exists('alt', $attrs) && is_numeric($attrs['alt'])) {
|
||||
$point[] = (float) $attrs['alt'];
|
||||
}
|
||||
|
||||
|
||||
78
bin/build.js
Normal file
78
bin/build.js
Normal file
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* esbuild script for Filament AlpineComponent bundles.
|
||||
*
|
||||
* Compiles each entry point into a self-contained ES module with all
|
||||
* dependencies inlined. Output is consumed by Filament's
|
||||
* `php artisan filament:assets`, which copies it into /public so the
|
||||
* `x-load` / `x-load-src` lazy loader can fetch it on demand.
|
||||
*
|
||||
* node bin/build.js # one-shot production build
|
||||
* node bin/build.js --dev # watch + inline sourcemaps
|
||||
*
|
||||
* Add new components: drop a `compile({...})` call at the bottom.
|
||||
*/
|
||||
import * as esbuild from "esbuild";
|
||||
|
||||
const isDev = process.argv.includes("--dev");
|
||||
|
||||
async function compile(options) {
|
||||
const context = await esbuild.context(options);
|
||||
|
||||
if (isDev) {
|
||||
await context.watch();
|
||||
} else {
|
||||
await context.rebuild();
|
||||
await context.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
define: {
|
||||
"process.env.NODE_ENV": isDev ? `'development'` : `'production'`,
|
||||
},
|
||||
bundle: true,
|
||||
mainFields: ["module", "main"],
|
||||
platform: "neutral",
|
||||
sourcemap: isDev ? "inline" : false,
|
||||
sourcesContent: isDev,
|
||||
treeShaking: true,
|
||||
target: ["es2020"],
|
||||
minify: !isDev,
|
||||
plugins: [
|
||||
{
|
||||
name: "watchPlugin",
|
||||
setup(build) {
|
||||
build.onStart(() => {
|
||||
console.log(
|
||||
`Build started at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`,
|
||||
);
|
||||
});
|
||||
|
||||
build.onEnd((result) => {
|
||||
if (result.errors.length > 0) {
|
||||
console.log(
|
||||
`Build failed at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`,
|
||||
result.errors,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Build finished at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
compile({
|
||||
...defaultOptions,
|
||||
entryPoints: ["./resources/js/admin/components/pirep-performance-chart.js"],
|
||||
outfile: "./resources/js/dist/admin/components/pirep-performance-chart.js",
|
||||
});
|
||||
|
||||
compile({
|
||||
...defaultOptions,
|
||||
entryPoints: ["./resources/js/admin/components/pirep-landing-analysis.js"],
|
||||
outfile: "./resources/js/dist/admin/components/pirep-landing-analysis.js",
|
||||
});
|
||||
@ -1,18 +1,18 @@
|
||||
# This Docker Compose file is used by Laravel Sail. It should not be used in production and is only
|
||||
# useful in a development environment. See https://laravel.com/docs/10.x/sail
|
||||
services:
|
||||
laravel.test:
|
||||
build:
|
||||
context: ./vendor/laravel/sail/runtimes/8.4
|
||||
context: ./vendor/laravel/sail/runtimes/8.5
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
WWWGROUP: "${WWWGROUP}"
|
||||
image: sail-8.4/app
|
||||
image: sail-8.5/app
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${APP_PORT:-80}:80"
|
||||
- "${VITE_PORT:-5173}:${VITE_PORT:-5173}"
|
||||
#env_file:
|
||||
# - .env
|
||||
environment:
|
||||
WWWUSER: "${WWWUSER}"
|
||||
LARAVEL_SAIL: 1
|
||||
@ -24,11 +24,11 @@ services:
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
- mariadb
|
||||
- mysql
|
||||
- redis
|
||||
- mailpit
|
||||
mariadb:
|
||||
image: "mariadb:10"
|
||||
mysql:
|
||||
image: "mysql:8.4"
|
||||
ports:
|
||||
- "${FORWARD_DB_PORT:-3306}:3306"
|
||||
environment:
|
||||
@ -37,9 +37,10 @@ services:
|
||||
MYSQL_DATABASE: "${DB_DATABASE}"
|
||||
MYSQL_USER: "${DB_USERNAME}"
|
||||
MYSQL_PASSWORD: "${DB_PASSWORD}"
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: 1
|
||||
MYSQL_EXTRA_OPTIONS: "${MYSQL_EXTRA_OPTIONS:-}"
|
||||
volumes:
|
||||
- "sail-mariadb:/var/lib/mysql"
|
||||
- "sail-mysql:/var/lib/mysql"
|
||||
- "./vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh"
|
||||
networks:
|
||||
- sail
|
||||
@ -77,7 +78,7 @@ networks:
|
||||
sail:
|
||||
driver: bridge
|
||||
volumes:
|
||||
sail-mariadb:
|
||||
sail-mysql:
|
||||
driver: local
|
||||
sail-redis:
|
||||
driver: local
|
||||
@ -44,7 +44,7 @@
|
||||
"psr/container": "1.1.1",
|
||||
"composer/composer": "^2.9.3",
|
||||
"composer/installers": "^2.3.0",
|
||||
"laravel/framework": "^v13.7",
|
||||
"laravel/framework": "^v13.9",
|
||||
"arrilot/laravel-widgets": "^3.14.0",
|
||||
"guzzlehttp/guzzle": "^7.9.0",
|
||||
"hashids/hashids": "~4.1.0",
|
||||
@ -115,7 +115,8 @@
|
||||
"barryvdh/laravel-ide-helper": "^3.7.0",
|
||||
"larastan/larastan": "^3.9.6",
|
||||
"fruitcake/laravel-debugbar": "^4.2.8",
|
||||
"laravel/boost": "^2.0"
|
||||
"laravel/boost": "^2.0",
|
||||
"laravel/tinker": "^3.0"
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
@ -151,15 +152,15 @@
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi",
|
||||
"@php artisan filament:upgrade",
|
||||
"@php artisan ide-helper:generate",
|
||||
"@php artisan ide-helper:meta",
|
||||
".hooks/setup-git-hooks.sh"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postUpdate",
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
|
||||
"@php artisan ide-helper:generate",
|
||||
"@php artisan ide-helper:meta",
|
||||
"@php artisan vendor:publish --tag=log-viewer-assets --ansi --force"
|
||||
"@php artisan vendor:publish --tag=log-viewer-assets --ansi --force",
|
||||
"@php artisan filament:upgrade"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
@ -198,7 +199,7 @@
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve --host 0.0.0.0\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
|
||||
846
composer.lock
generated
846
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -40,11 +40,6 @@ return [
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'seeds' => [
|
||||
'driver' => 'local',
|
||||
'root' => database_path('seeders'),
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
|
||||
@ -20,7 +20,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
'default' => env('LOG_CHANNEL', 'daily'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@ -56,7 +56,7 @@ return [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'daily')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
@ -86,7 +86,7 @@ return [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'days' => env('LOG_DAILY_DAYS', 7),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
|
||||
@ -176,8 +176,8 @@ return [
|
||||
* protect the API from oversized result sets.
|
||||
*/
|
||||
'pagination' => [
|
||||
'limit' => 50,
|
||||
'max' => 100,
|
||||
'limit' => env('PHPVMS_PAGINATION_LIMIT', 50),
|
||||
'max' => env('PHPVMS_PAGINATION_MAX', 100),
|
||||
],
|
||||
|
||||
/**
|
||||
|
||||
@ -68,4 +68,11 @@ return [
|
||||
'scopes' => env('IVAO_SCOPES', '') === '' ? [] : explode(',', (string) env('IVAO_SCOPES', '')),
|
||||
'redirect' => '/oauth/ivao/callback',
|
||||
],
|
||||
|
||||
'openaip' => [
|
||||
// OpenAIP airspace + nav-aid tile overlay. Free key from
|
||||
// https://www.openaip.net/users/clients (requires account).
|
||||
// Empty key = overlay is silently disabled, base map still renders.
|
||||
'api_key' => env('OPENAIP_API_KEY', ''),
|
||||
],
|
||||
];
|
||||
|
||||
@ -10,7 +10,7 @@ use App\Services\FareService;
|
||||
*/
|
||||
return new class() extends Migration
|
||||
{
|
||||
public function up()
|
||||
public function up(): void
|
||||
{
|
||||
$cached = [];
|
||||
$fareSvc = app(FareService::class);
|
||||
|
||||
@ -5,15 +5,13 @@ declare(strict_types=1);
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Services\Installer\MigrationService;
|
||||
use App\Services\Installer\SeederService;
|
||||
use Exception;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MigrationService $migrationSvc,
|
||||
private readonly SeederService $seederSvc
|
||||
private readonly MigrationService $migrationSvc
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -23,16 +21,13 @@ class DatabaseSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Make sure any migrations that need to be run are run/cleared out
|
||||
if ($this->migrationSvc->migrationsAvailable()) {
|
||||
$this->migrationSvc->runAllMigrations();
|
||||
}
|
||||
|
||||
// Then sync all the seeds
|
||||
$this->seederSvc->syncAllSeeds();
|
||||
|
||||
$this->call([
|
||||
ShieldSeeder::class,
|
||||
YamlSeeder::class,
|
||||
]);
|
||||
|
||||
if ($this->migrationSvc->dataMigrationsAvailable()) {
|
||||
$this->migrationSvc->runAllDataMigrations();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
198
database/seeders/YamlSeeder.php
Executable file
198
database/seeders/YamlSeeder.php
Executable file
@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Services\Installer\SeederService;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Finder\SplFileInfo;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
use function in_array;
|
||||
|
||||
class YamlSeeder extends Seeder
|
||||
{
|
||||
protected array $uuidTables = [
|
||||
'acars',
|
||||
'flights',
|
||||
'pireps',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly SeederService $seederSvc
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->seedFromYamlFile(database_path('seeders/base.yml'));
|
||||
|
||||
// Special method to sync the settings
|
||||
$this->seederSvc->syncAllSettings();
|
||||
|
||||
$env = App::environment();
|
||||
$seedPath = database_path('seeders/'.$env);
|
||||
if (!File::isDirectory($seedPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('current environment '.$env);
|
||||
|
||||
collect(File::allFiles($seedPath))
|
||||
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'yml')
|
||||
->each(function (SplFileInfo $file): void {
|
||||
$path = $file->getPathname();
|
||||
Log::info('reading '.$path);
|
||||
$this->seedFromYamlFile($path);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function seedFromYamlFile(string $yaml_file, bool $ignore_errors = false): array
|
||||
{
|
||||
$yml = file_get_contents($yaml_file);
|
||||
if ($yml === false) {
|
||||
throw new \RuntimeException('Unable to read YAML seed file: '.$yaml_file);
|
||||
}
|
||||
|
||||
$yml = Yaml::parse($yml);
|
||||
|
||||
return $this->seedFromYaml($yml, $ignore_errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function seedFromYaml(mixed $yml, bool $ignore_errors = false): array
|
||||
{
|
||||
$imported = [];
|
||||
|
||||
if (empty($yml)) {
|
||||
return $imported;
|
||||
}
|
||||
|
||||
foreach ($yml as $table => $data) {
|
||||
// set the number imported to zero
|
||||
$imported[$table] = 0;
|
||||
|
||||
$id_column = 'id';
|
||||
if (array_key_exists('id_column', $data)) {
|
||||
$id_column = $data['id_column'];
|
||||
}
|
||||
|
||||
$ignore_on_update = [];
|
||||
if (array_key_exists('ignore_on_update', $data)) {
|
||||
$ignore_on_update = $data['ignore_on_update'];
|
||||
}
|
||||
|
||||
$ignore_if_exists = false;
|
||||
if (array_key_exists('ignore_if_exists', $data)) {
|
||||
$ignore_if_exists = $data['ignore_if_exists'];
|
||||
}
|
||||
|
||||
$rows = array_key_exists('data', $data) ? $data['data'] : $data;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
try {
|
||||
$this->insertRow(
|
||||
$table,
|
||||
$row,
|
||||
$id_column,
|
||||
$ignore_on_update,
|
||||
$ignore_errors,
|
||||
$ignore_if_exists
|
||||
);
|
||||
} catch (QueryException $e) {
|
||||
if ($ignore_errors) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$imported[$table]++;
|
||||
}
|
||||
}
|
||||
|
||||
return $imported;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function insertRow(
|
||||
string $table,
|
||||
array $row = [],
|
||||
string $id_col = 'id',
|
||||
array $ignore_on_updates = [],
|
||||
bool $ignore_errors = true,
|
||||
bool $ignore_if_exists = true,
|
||||
): array {
|
||||
if ($row === []) {
|
||||
return $row;
|
||||
}
|
||||
|
||||
if (!array_key_exists('id', $row) && in_array($table, $this->uuidTables, true)) {
|
||||
$row['id'] = Str::uuid();
|
||||
}
|
||||
|
||||
// encrypt any password fields
|
||||
if (array_key_exists('password', $row)) {
|
||||
$row['password'] = bcrypt($row['password']);
|
||||
}
|
||||
|
||||
// if any time fields are == to "now", then insert the right time
|
||||
foreach ($row as $column => $value) {
|
||||
if (!empty($value) && strtolower((string) $value) === 'now') {
|
||||
$row[$column] = Carbon::now('UTC');
|
||||
}
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
if (array_key_exists($id_col, $row)) {
|
||||
$count = DB::table($table)->where($id_col, $row[$id_col])->count($id_col);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($count > 0) {
|
||||
if ($ignore_if_exists) {
|
||||
return $row;
|
||||
}
|
||||
|
||||
foreach ($ignore_on_updates as $ignore_column) {
|
||||
if (array_key_exists($ignore_column, $row)) {
|
||||
unset($row[$ignore_column]);
|
||||
}
|
||||
}
|
||||
|
||||
DB::table($table)
|
||||
->where($id_col, $row[$id_col])
|
||||
->update($row);
|
||||
} else {
|
||||
DB::table($table)->insert($row);
|
||||
}
|
||||
} catch (QueryException $queryException) {
|
||||
Log::error('Error while running query: '.$queryException->getMessage(), ['exception' => $queryException]);
|
||||
if (!$ignore_errors) {
|
||||
throw $queryException;
|
||||
}
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
94
justfile
Normal file
94
justfile
Normal file
@ -0,0 +1,94 @@
|
||||
# phpVMS task runner
|
||||
#
|
||||
# Install just: brew install just (or cargo install just)
|
||||
# List recipes: just --list
|
||||
|
||||
set shell := ["bash", "-cu"]
|
||||
|
||||
# Default: list all recipes
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# ── Frontend ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Vite dev server (theme + admin map bundle, HMR)
|
||||
dev:
|
||||
npm run dev
|
||||
|
||||
# Watch Alpine components (esbuild --dev, inline sourcemaps)
|
||||
dev-components:
|
||||
npm run dev:components
|
||||
|
||||
# Production build: Vite (theme + admin) + esbuild (Alpine components) + Filament asset publish
|
||||
build:
|
||||
npm run build
|
||||
php artisan filament:assets
|
||||
|
||||
# Vite only — theme + admin map bundle
|
||||
build-vite:
|
||||
npm run build
|
||||
|
||||
# esbuild only — Filament AlpineComponent bundles
|
||||
build-components:
|
||||
npm run build:components
|
||||
php artisan filament:assets
|
||||
|
||||
# Publish Filament assets into /public (AlpineComponents, plugin CSS/JS)
|
||||
publish:
|
||||
php artisan filament:assets
|
||||
|
||||
# ── Linting / Formatting ────────────────────────────────────────────────────
|
||||
|
||||
# JS lint (oxlint)
|
||||
lint-js:
|
||||
npm run lint
|
||||
|
||||
lint-js-fix:
|
||||
npm run lint:fix
|
||||
|
||||
# JS format (oxfmt)
|
||||
fmt-js:
|
||||
npm run fmt
|
||||
|
||||
fmt-js-check:
|
||||
npm run fmt:check
|
||||
|
||||
# PHP format (Pint, dirty files only)
|
||||
fmt-php:
|
||||
vendor/bin/pint --dirty
|
||||
|
||||
fmt-php-check:
|
||||
vendor/bin/pint --test
|
||||
|
||||
# ── Static Analysis ─────────────────────────────────────────────────────────
|
||||
|
||||
# PHPStan / Larastan (level 5)
|
||||
stan:
|
||||
vendor/bin/phpstan analyse
|
||||
|
||||
# Rector dry-run (no writes)
|
||||
rector-dry:
|
||||
vendor/bin/rector --dry-run
|
||||
|
||||
# Rector apply (writes changes)
|
||||
rector:
|
||||
vendor/bin/rector
|
||||
|
||||
# ── Testing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Full Pest suite
|
||||
test:
|
||||
composer test
|
||||
|
||||
# Filter tests: `just test-filter PirepTest`
|
||||
test-filter filter:
|
||||
php artisan test --compact --filter={{filter}}
|
||||
|
||||
# ── Pre-PR Checklist (matches AGENTS.md) ────────────────────────────────────
|
||||
|
||||
# Run all four gates: pint, phpstan, pest, rector
|
||||
check:
|
||||
composer pint --test
|
||||
vendor/bin/phpstan analyse
|
||||
composer test
|
||||
vendor/bin/rector --dry-run
|
||||
544
package-lock.json
generated
544
package-lock.json
generated
@ -7,10 +7,14 @@
|
||||
"dependencies": {
|
||||
"axios": "^1.15.0",
|
||||
"bootstrap": "~4.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"cookieconsent": "^3.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"jquery": "^3.5.1",
|
||||
"leaflet": "^1.3.4",
|
||||
"leaflet-providers": "1.0.*",
|
||||
"leaflet-providers": "^2.0.0",
|
||||
"leaflet-rotatedmarker": "^0.2.0",
|
||||
"leaflet.geodesic": "^2.5.2",
|
||||
"moment": "^2.29.1",
|
||||
@ -20,6 +24,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"laravel-vite-plugin": "^3.0.0",
|
||||
"oxfmt": "^0.41.0",
|
||||
"oxlint": "^1.56.0",
|
||||
@ -62,6 +67,448 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@ -112,6 +559,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
@ -1759,6 +2212,37 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-adapter-date-fns": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
|
||||
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=2.8.0",
|
||||
"date-fns": ">=2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-plugin-annotation": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
|
||||
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@ -1793,6 +2277,16 @@
|
||||
"integrity": "sha512-v8JWLJcI7Zs9NWrs8hiVldVtm3EBF70TJI231vxn6YToBGj0c9dvdnYwltydkAnrbBMOM/qX1xLFrnTfm5wTag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@ -1885,6 +2379,48 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@ -2134,9 +2670,9 @@
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/leaflet-providers": {
|
||||
"version": "1.0.29",
|
||||
"resolved": "https://registry.npmjs.org/leaflet-providers/-/leaflet-providers-1.0.29.tgz",
|
||||
"integrity": "sha512-ZQxlJVSriz3cwYKSUAHxYTx853afzxh9vkK45dE3Don4pUXLuHpUFOALdKayPtanQ2uE1930PcO5gKBlmaff0Q==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/leaflet-providers/-/leaflet-providers-2.0.0.tgz",
|
||||
"integrity": "sha512-CWwKEnHd66Qsx0m4o5q5ZOa60s00B91pMxnlr4Y22msubfs7dhbZhdMIz8bvZQkrZqi67ppI1fsZRS6vtrLcOA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/leaflet-rotatedmarker": {
|
||||
|
||||
12
package.json
12
package.json
@ -3,7 +3,10 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"dev:components": "node bin/build.js --dev",
|
||||
"build": "vite build && node bin/build.js",
|
||||
"build:components": "node bin/build.js",
|
||||
"clean": "rm -rf public/build resources/js/dist",
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"fmt": "oxfmt",
|
||||
@ -12,10 +15,14 @@
|
||||
"dependencies": {
|
||||
"axios": "^1.15.0",
|
||||
"bootstrap": "~4.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"cookieconsent": "^3.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"jquery": "^3.5.1",
|
||||
"leaflet": "^1.3.4",
|
||||
"leaflet-providers": "1.0.*",
|
||||
"leaflet-providers": "^2.0.0",
|
||||
"leaflet-rotatedmarker": "^0.2.0",
|
||||
"leaflet.geodesic": "^2.5.2",
|
||||
"moment": "^2.29.1",
|
||||
@ -25,6 +32,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"laravel-vite-plugin": "^3.0.0",
|
||||
"oxfmt": "^0.41.0",
|
||||
"oxlint": "^1.56.0",
|
||||
|
||||
1
public/assets/img/logo_blue.svg
Normal file
1
public/assets/img/logo_blue.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 274.21"><defs><style>.cls-2{fill:#067ec1;}</style></defs><title>logo_blue</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_2-2" data-name="Layer 2"><polygon class="cls-2" points="186.37 73.83 186.37 62.58 152.29 62.58 98.93 131.61 44.71 61.45 6.27 61.45 6.27 72.71 39.18 72.71 60.13 99.81 22.65 219.61 33.4 222.97 68.52 110.67 98.93 150.01 128.61 111.62 163.43 222.97 174.18 219.61 137 100.76 157.81 73.83 186.37 73.83"/><path class="cls-2" d="M151.66,27.41H124.3a16.58,16.58,0,0,0-16.58-16.58H92.8A16.58,16.58,0,0,0,76.22,27.41H48.86l-9.95,9.95H44.6L61.49,73.83H139l16.88-36.48h5.68ZM77.05,60.57H60.47V44H77.05Zm21,0H81.47V44H98.05Zm21,0H102.47V44h16.58Zm21,0H123.48V44h16.58Z"/><path class="cls-2" d="M96.64,263.44c-19.14,0-39-7.13-53.06-19.08l-2.41-2,14-28.71,4,3.56c10.67,9.48,26.2,16.11,37.77,16.11,4.82,0,12.9-.89,12.9-6.87,0-5.59-7.7-8.14-20.38-11.68C72.4,210,49,203.44,49,177h8.14c0,20.31,17.56,25.2,34.54,29.94C104.62,210.5,118,214.23,118,226.4c0,9.4-7.87,15-21,15-12.14,0-27.28-5.82-39.08-14.79l-6.6,13.51A76.4,76.4,0,0,0,96.64,255.3ZM145.42,225l-8.14,0c.08-20.91-17.69-26.08-34.87-31.07C89.13,190,76.57,186.34,76.57,174.5c0-9.86,9.61-13.37,18.59-13.37s22.19,4,33.74,10.12l6.57-14.12a79.1,79.1,0,0,0-38.19-9.9V139.1c16.13,0,33.19,4.89,45.65,13.07l2.94,1.93-13.34,28.65-3.87-2.3c-11.07-6.58-24.85-11.18-33.5-11.18-3.14,0-10.46.51-10.46,5.23,0,5.34,7.55,7.92,20,11.53C121.92,191,145.53,197.9,145.42,225Z"/><circle class="cls-2" cx="98.59" cy="142.88" r="7.69" transform="translate(-72.16 111.56) rotate(-45)"/><circle class="cls-2" cx="94.07" cy="259.54" r="7.69" transform="translate(-155.97 142.53) rotate(-45)"/><circle class="cls-2" cx="141.09" cy="224.27" r="7.69" transform="translate(-117.26 165.46) rotate(-45)"/><circle class="cls-2" cx="53.38" cy="177.25" r="7.69" transform="translate(-109.7 89.66) rotate(-45)"/><circle class="cls-2" cx="184.98" cy="68.72" r="7.44" transform="translate(41.12 203.79) rotate(-63.57)"/><circle class="cls-2" cx="8.47" cy="67.28" r="7.44" transform="translate(-55.55 44.92) rotate(-63.57)"/><circle class="cls-2" cx="27.84" cy="220.84" r="7.44" transform="translate(-182.31 147.48) rotate(-63.57)"/><circle class="cls-2" cx="167.76" cy="219.4" r="7.44" transform="translate(-103.38 271.99) rotate(-63.57)"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
function v({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:r}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,unsubscribeLivewireHook:null,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(r)&&t.includes(e.get(r))&&(this.tab=e.get(r)),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),this.$watch("tab",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0),this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:i,onSuccess:a})=>{a(()=>{this.$nextTick(()=>{if(i.component.id!==g)return;let l=this.getTabs();l.includes(this.tab)||(this.tab=l[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,i,a,l,h){let u=t.map(n=>Math.ceil(n.clientWidth)),b=t.map(n=>{let c=n.querySelector(".fi-tabs-item-label"),s=n.querySelector(".fi-badge"),o=Math.ceil(c.clientWidth),d=s?Math.ceil(s.clientWidth):0;return{label:o,badge:d,total:o+(d>0?a+d:0)}});for(let n=0;n<t.length;n++){let c=u.slice(0,n+1).reduce((p,y)=>p+y,0),s=n*i,o=b.slice(n+1),d=o.length>0,D=d?Math.max(...o.map(p=>p.total)):0,W=d?l+D+a+h+i:0;if(c+s+W>e)return n}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(r,this.tab),history.replaceState(null,document.title,t.toString())},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$el.querySelectorAll(".fi-sc-tabs-tab.fi-active [autofocus]");for(let i of e)if(i.focus(),document.activeElement===i)break})},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),i=Array.from(t.children).slice(0,-1),a=i.map(s=>s.style.display);i.forEach(s=>s.style.display=""),t.offsetHeight;let l=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),b=this.calculateTabItemGap(i[0]),n=this.calculateTabItemPadding(i[0]),c=this.findOverflowIndex(i,l,h,b,n,u);i.forEach((s,o)=>s.style.display=a[o]),c!==-1&&(this.withinDropdownIndex=c),this.withinDropdownMounted=!0},destroy(){this.unsubscribeLivewireHook?.(),this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{v as default};
|
||||
function x({activeTab:h,isScrollable:m,isTabPersisted:T,isTabPersistedInQueryString:u,livewireId:g,schemaKey:D,tab:W,tabQueryStringKey:r}){return{boundResizeHandler:null,boundResetHandler:null,isScrollable:m,resizeDebounceTimer:null,tab:W,unsubscribeLivewireHook:null,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);u&&e.has(r)&&t.includes(e.get(r))&&(this.tab=e.get(r)),(!this.tab||!t.includes(this.tab))&&(this.tab=t[h-1]),this.$watch("tab",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0),this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:i,onSuccess:a})=>{a(()=>{this.$nextTick(()=>{if(i.component.id!==g)return;let l=this.getTabs();l.includes(this.tab)||(this.tab=l[h-1]??this.tab)})})}),this.boundResetHandler=i=>{i.detail.livewireId!==g||i.detail.schemaKey!==D||T||u||this.$nextTick(()=>{this.tab=this.getTabs()[h-1]??this.tab})},window.addEventListener("reset-schema-component-state",this.boundResetHandler),m||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,i,a,l,b){let p=t.map(n=>Math.ceil(n.clientWidth)),w=t.map(n=>{let d=n.querySelector(".fi-tabs-item-label"),s=n.querySelector(".fi-badge"),o=Math.ceil(d.clientWidth),c=s?Math.ceil(s.clientWidth):0;return{label:o,badge:c,total:o+(c>0?a+c:0)}});for(let n=0;n<t.length;n++){let d=p.slice(0,n+1).reduce((f,I)=>f+I,0),s=n*i,o=w.slice(n+1),c=o.length>0,v=c?Math.max(...o.map(f=>f.total)):0,y=c?l+v+a+b+i:0;if(d+s+y>e)return n}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!u)return;let t=new URL(window.location.href);t.searchParams.set(r,this.tab),history.replaceState(null,document.title,t.toString())},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$el.querySelectorAll(".fi-sc-tabs-tab.fi-active [autofocus]");for(let i of e)if(i.focus(),document.activeElement===i)break})},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),i=Array.from(t.children).slice(0,-1),a=i.map(s=>s.style.display);i.forEach(s=>s.style.display=""),t.offsetHeight;let l=this.calculateAvailableWidth(t),b=this.calculateContainerGap(t),p=this.calculateDropdownIconWidth(e),w=this.calculateTabItemGap(i[0]),n=this.calculateTabItemPadding(i[0]),d=this.findOverflowIndex(i,l,b,w,n,p);i.forEach((s,o)=>s.style.display=a[o]),d!==-1&&(this.withinDropdownIndex=d),this.withinDropdownMounted=!0},destroy(){this.unsubscribeLivewireHook?.(),this.boundResetHandler&&window.removeEventListener("reset-schema-component-state",this.boundResetHandler),this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{x as default};
|
||||
|
||||
@ -1 +1 @@
|
||||
function p({isSkippable:i,isStepPersistedInQueryString:n,key:r,startStep:o,stepQueryStringKey:h}){return{step:null,init(){this.step=this.getSteps().at(o-1),this.$watch("step",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0)},async requestNextStep(){await this.$wire.callSchemaComponentMethod(r,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.scroll())},goToStep(t){let e=this.getStepIndex(t);e<=-1||!i&&e>this.getStepIndex(this.step)||(this.step=t,this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$refs[`step-${this.step}`]?.querySelectorAll("[autofocus]")??[];for(let s of e)if(s.focus(),document.activeElement===s)break})},getStepIndex(t){let e=this.getSteps().findIndex(s=>s===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return i||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!n)return;let t=new URL(window.location.href);t.searchParams.set(h,this.step),history.replaceState(null,document.title,t.toString())}}}export{p as default};
|
||||
function l({isSkippable:i,isStepPersistedInQueryString:n,key:o,livewireId:h,schemaKey:p,startStep:r,stepQueryStringKey:d}){return{boundResetHandler:null,step:null,init(){this.step=this.getSteps().at(r-1),this.$watch("step",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0),this.boundResetHandler=t=>{t.detail.livewireId!==h||t.detail.schemaKey!==p||n||this.$nextTick(()=>{this.step=this.getSteps().at(r-1)??this.step})},window.addEventListener("reset-schema-component-state",this.boundResetHandler)},async requestNextStep(){await this.$wire.callSchemaComponentMethod(o,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.scroll())},goToStep(t){let e=this.getStepIndex(t);e<=-1||!i&&e>this.getStepIndex(this.step)||(this.step=t,this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$refs[`step-${this.step}`]?.querySelectorAll("[autofocus]")??[];for(let s of e)if(s.focus(),document.activeElement===s)break})},getStepIndex(t){let e=this.getSteps().findIndex(s=>s===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return i||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!n)return;let t=new URL(window.location.href);t.searchParams.set(d,this.step),history.replaceState(null,document.title,t.toString())},destroy(){this.boundResetHandler&&window.removeEventListener("reset-schema-component-state",this.boundResetHandler)}}}export{l as default};
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
61
resources/js/admin/app.js
Normal file
61
resources/js/admin/app.js
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Admin Filament panel JavaScript entry.
|
||||
*
|
||||
* Built by Vite and injected into every admin page via the
|
||||
* PanelsRenderHook::HEAD_END render hook in AdminPanelProvider, which uses
|
||||
* `@vite('resources/js/admin/app.js')` so manifest resolution only happens
|
||||
* at HTTP render time (never during console boot — see provider for full
|
||||
* rationale).
|
||||
*
|
||||
* The `maps` module statically imports Leaflet + its plugins (~150kB), so
|
||||
* it's loaded via dynamic `import()` here. Vite code-splits it into its own
|
||||
* chunk that the browser only fetches when an admin blade actually calls
|
||||
* `window.phpvms.map.render_route_map(...)` etc. Admin pages without a map
|
||||
* pay no cost beyond this thin entry.
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
import config from "./config";
|
||||
import request from "./request";
|
||||
import Storage from "./storage";
|
||||
|
||||
window.axios = axios;
|
||||
|
||||
// Lazy-load the maps chunk on first call. Subsequent calls reuse the
|
||||
// resolved module via the cached promise (ES module spec dedupes by URL).
|
||||
let mapsModulePromise = null;
|
||||
const loadMaps = () => {
|
||||
if (!mapsModulePromise) {
|
||||
mapsModulePromise = import("./maps");
|
||||
}
|
||||
|
||||
return mapsModulePromise;
|
||||
};
|
||||
|
||||
window.phpvms = {
|
||||
config,
|
||||
request,
|
||||
Storage,
|
||||
map: {
|
||||
render_route_map: async (...args) => {
|
||||
const maps = await loadMaps();
|
||||
|
||||
return maps.render_route_map(...args);
|
||||
},
|
||||
render_base_map: async (...args) => {
|
||||
const maps = await loadMaps();
|
||||
|
||||
return maps.render_base_map(...args);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Signal readiness for blade init scripts that race the ES module load.
|
||||
// `@vite` injects this file as `<script type="module">`, which defers
|
||||
// execution until after DOM parsing. Alpine's x-data init() fires on
|
||||
// DOMContentLoaded — which can land before this module finishes executing
|
||||
// on some browsers. Blades that need `window.phpvms` should await this
|
||||
// promise inside init() rather than touching `window.phpvms` directly.
|
||||
window.phpvmsReady = Promise.resolve(window.phpvms);
|
||||
window.dispatchEvent(new CustomEvent("phpvms:ready", { detail: window.phpvms }));
|
||||
249
resources/js/admin/components/pirep-landing-analysis.js
Normal file
249
resources/js/admin/components/pirep-landing-analysis.js
Normal file
@ -0,0 +1,249 @@
|
||||
/**
|
||||
* PIREP Landing Analysis — runway plan-views + scorecard polar chart.
|
||||
* Registered as a Filament AlpineComponent, lazy-loaded via x-load /
|
||||
* x-load-src so Chart.js is only fetched on PIREP detail pages.
|
||||
*
|
||||
* Usage from blade (Alpine):
|
||||
*
|
||||
* <div x-load
|
||||
* x-load-src="{{ FilamentAsset::getAlpineComponentSrc('pirep-landing-analysis') }}"
|
||||
* x-data="pirepLandingAnalysis(@js($landing))">
|
||||
* <canvas x-ref="scorecard"></canvas>
|
||||
* </div>
|
||||
*/
|
||||
|
||||
import Chart from "chart.js/auto";
|
||||
|
||||
// Runway schematic dimensions (SVG viewBox units).
|
||||
// 20m assumed runway width per project decision — visual only, no real-world
|
||||
// length needed. SVG aspect tuned so the centerline reads as a "long strip".
|
||||
const RW_VIEWBOX = { w: 200, h: 100 };
|
||||
const RW_CENTERLINE_Y = RW_VIEWBOX.h / 2;
|
||||
const RW_RUNWAY_WIDTH = 32;
|
||||
|
||||
// Centerline-offset clamp range (meters or feet — units defined by ACARS
|
||||
// client). Beyond ±30 we just pin the marker to the edge and let the numeric
|
||||
// label tell the truth.
|
||||
const OFFSET_CLAMP = 30;
|
||||
|
||||
// Heading deviation visual exaggeration. Real-world dev is usually <2° — at
|
||||
// SVG scale that's invisible. Multiplying by 8 (clamped to ±45° to avoid
|
||||
// nonsense rotations) gives a clear visual signal while the numeric label
|
||||
// below the diagram carries the precise value.
|
||||
const HEADING_MULTIPLIER = 8;
|
||||
const HEADING_CLAMP = 45;
|
||||
|
||||
const SCORE_AXIS_LABELS = {
|
||||
rate: "Touchdown",
|
||||
g_force: "G-force",
|
||||
pitch: "Pitch",
|
||||
roll: "Roll",
|
||||
centerline: "Centerline",
|
||||
heading: "Heading",
|
||||
};
|
||||
|
||||
// Color bands for individual metric scores. Used both in scorecard tooltip
|
||||
// and in runway-diagram severity tints.
|
||||
function severityColor(score) {
|
||||
if (score >= 80) return "#10b981"; // emerald — excellent
|
||||
if (score >= 60) return "#84cc16"; // lime — acceptable
|
||||
if (score >= 40) return "#f59e0b"; // amber — degraded
|
||||
return "#ef4444"; // red — bad
|
||||
}
|
||||
|
||||
export default function pirepLandingAnalysis(payload) {
|
||||
return {
|
||||
payload,
|
||||
chart: null,
|
||||
departureMarker: null,
|
||||
arrivalMarker: null,
|
||||
attitude: null,
|
||||
|
||||
init() {
|
||||
if (!this.payload) return;
|
||||
|
||||
// Pre-compute runway markers so the SVG template binds against
|
||||
// stable Alpine state rather than calling a method from inside
|
||||
// <template x-if> (which Alpine evaluates eagerly).
|
||||
this.departureMarker = this.computeMarker("departure");
|
||||
this.arrivalMarker = this.computeMarker("arrival");
|
||||
this.attitude = this.computeAttitude();
|
||||
|
||||
this.$nextTick(() => this.renderScorecard());
|
||||
},
|
||||
|
||||
/**
|
||||
* Build attitude indicator state at touchdown.
|
||||
* - roll: aircraft bank angle (positive = right wing down). Rotates
|
||||
* the horizon group (real value, 1:1 — typical touchdown roll is
|
||||
* under 5° so direct mapping reads correctly).
|
||||
* - pitch: nose attitude (positive = nose up). Shifts the horizon
|
||||
* vertically. Pitch ladder marks are at 5° / 10° = 15px / 30px
|
||||
* from horizon (3px per degree), so pitchOffset uses the same
|
||||
* 3px/deg scale to keep readout consistent with the ladder.
|
||||
*
|
||||
* Both clamped so an outlier (sensor glitch, hard touchdown) doesn't
|
||||
* rotate the AI past readability.
|
||||
*/
|
||||
computeAttitude() {
|
||||
const sc = this.payload?.scorecard;
|
||||
const roll = sc?.roll?.value ?? null;
|
||||
const pitch = sc?.pitch?.value ?? null;
|
||||
|
||||
if (roll === null && pitch === null) return null;
|
||||
|
||||
const rollDeg = roll ?? 0;
|
||||
const pitchDeg = pitch ?? 0;
|
||||
|
||||
// Real-degree mapping for both axes; clamp prevents extreme outliers
|
||||
// from rotating/translating off-screen.
|
||||
const rollRotation = Math.max(-30, Math.min(30, rollDeg));
|
||||
const pitchPxPerDeg = 3;
|
||||
const pitchOffset = Math.max(-30, Math.min(30, pitchDeg * pitchPxPerDeg));
|
||||
|
||||
return {
|
||||
roll: rollDeg,
|
||||
pitch: pitchDeg,
|
||||
rollRotation,
|
||||
pitchOffset,
|
||||
rollScore: sc?.roll?.score ?? 0,
|
||||
pitchScore: sc?.pitch?.score ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Build a Chart.js polar-area chart showing each landing metric's score
|
||||
* on a 0–100 scale. Filled polygon = visual signature of the landing;
|
||||
* circular = balanced/clean, spiky = uneven.
|
||||
*/
|
||||
renderScorecard() {
|
||||
const sc = this.payload?.scorecard;
|
||||
if (!sc) return;
|
||||
|
||||
const canvas = this.$refs?.scorecard;
|
||||
if (!canvas) return;
|
||||
|
||||
const axisKeys = Object.keys(SCORE_AXIS_LABELS);
|
||||
const scores = axisKeys.map((k) => sc[k]?.score ?? 0);
|
||||
const labels = axisKeys.map((k) => SCORE_AXIS_LABELS[k]);
|
||||
|
||||
// Tint the filled polygon by the *worst* axis score — a single bad
|
||||
// metric drags the visual signal, matching how a check airman would
|
||||
// read the landing. Solid polygon (semi-transparent) plus a stroked
|
||||
// outline so the shape stays legible even on near-perfect landings.
|
||||
const worstScore = Math.min(...scores);
|
||||
const polyColor = severityColor(worstScore);
|
||||
|
||||
if (this.chart) this.chart.destroy();
|
||||
|
||||
this.chart = new Chart(canvas, {
|
||||
type: "radar",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data: scores,
|
||||
backgroundColor: polyColor + "40", // 25% alpha fill
|
||||
borderColor: polyColor,
|
||||
borderWidth: 2,
|
||||
pointBackgroundColor: scores.map((s) => severityColor(s)),
|
||||
pointBorderColor: "#ffffff",
|
||||
pointBorderWidth: 1.5,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: { top: 8, bottom: 8, left: 12, right: 12 },
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
stepSize: 25,
|
||||
color: "#9ca3af",
|
||||
backdropColor: "transparent",
|
||||
font: { family: "Geist Mono", size: 9 },
|
||||
showLabelBackdrop: false,
|
||||
},
|
||||
grid: { color: "#e5e7eb" },
|
||||
angleLines: { color: "#e5e7eb" },
|
||||
pointLabels: {
|
||||
color: "#374151",
|
||||
font: { family: "Geist", size: 10, weight: "500" },
|
||||
padding: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
const key = axisKeys[ctx.dataIndex];
|
||||
const raw = sc[key]?.value;
|
||||
const rawFmt =
|
||||
raw === null || raw === undefined
|
||||
? "—"
|
||||
: typeof raw === "number"
|
||||
? raw.toFixed(2)
|
||||
: raw;
|
||||
return ` ${ctx.parsed.toFixed(0)} / 100 (raw: ${rawFmt})`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Compute SVG coordinates for the touchdown / rollout marker on a runway
|
||||
* plan-view. Returns marker X/Y, aircraft glyph rotation (heading dev),
|
||||
* severity tint (centerline score), and the raw values for debug/labels.
|
||||
*
|
||||
* Threshold is drawn at x=10 (left edge area). Departure rolls from
|
||||
* threshold → marker placed ~25% down the strip. Arrival touches down
|
||||
* a bit further past threshold → marker placed ~35% down.
|
||||
*/
|
||||
computeMarker(side) {
|
||||
const data = this.payload?.[side];
|
||||
if (!data) return null;
|
||||
|
||||
const offset = data.centerline_offset ?? 0;
|
||||
const heading = data.heading_deviation ?? 0;
|
||||
|
||||
const clampedOffset = Math.max(-OFFSET_CLAMP, Math.min(OFFSET_CLAMP, offset));
|
||||
const offsetRatio = clampedOffset / OFFSET_CLAMP;
|
||||
const y = RW_CENTERLINE_Y + offsetRatio * (RW_RUNWAY_WIDTH / 2 - 4);
|
||||
|
||||
// Both markers placed near the threshold (left side) since the
|
||||
// diagram has no scale — the offset from centerline carries the
|
||||
// signal, not where along the runway the wheels touched.
|
||||
const x = side === "arrival" ? 55 : 75;
|
||||
|
||||
// Exaggerate heading deviation so sub-degree values render visibly.
|
||||
const rotation = Math.max(
|
||||
-HEADING_CLAMP,
|
||||
Math.min(HEADING_CLAMP, heading * HEADING_MULTIPLIER),
|
||||
);
|
||||
|
||||
const score = this.payload?.scorecard?.centerline?.score ?? 0;
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
rotation,
|
||||
color: severityColor(score),
|
||||
offset,
|
||||
heading,
|
||||
runway: data.runway,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
244
resources/js/admin/components/pirep-performance-chart.js
Normal file
244
resources/js/admin/components/pirep-performance-chart.js
Normal file
@ -0,0 +1,244 @@
|
||||
/**
|
||||
* PIREP Performance chart — Chart.js v4.
|
||||
* Registered as a Filament AlpineComponent, lazy-loaded via x-load /
|
||||
* x-load-src so Chart.js is only fetched on PIREP detail pages.
|
||||
*
|
||||
* Usage from blade (Alpine):
|
||||
*
|
||||
* <div x-load
|
||||
* x-load-src="{{ FilamentAsset::getAlpineComponentSrc('pirep-performance-chart') }}"
|
||||
* x-data="pirepPerformanceChart(@js($performance))">
|
||||
* <canvas x-ref="canvas"></canvas>
|
||||
* </div>
|
||||
*/
|
||||
|
||||
import Chart from "chart.js/auto";
|
||||
import annotationPlugin from "chartjs-plugin-annotation";
|
||||
import "chartjs-adapter-date-fns";
|
||||
|
||||
Chart.register(annotationPlugin);
|
||||
|
||||
// Phase shading keyed off ACARS sample `status` (PirepStatus enum value).
|
||||
// Codes that don't appear here render unshaded — keeps unknown / SCH from
|
||||
// painting the whole chart gray. Labels come from the server payload
|
||||
// (`phase.label`) so translations stay in PHP land (PirepStatus::getLabel).
|
||||
// Low-alpha backgrounds so the data line stays visually dominant.
|
||||
const PHASE_COLORS = {
|
||||
// Ground / pre-flight
|
||||
BST: "rgba(148, 163, 184, 0.08)", // slate — boarding
|
||||
RDT: "rgba(148, 163, 184, 0.08)", // slate — ready start
|
||||
PBT: "rgba(148, 163, 184, 0.10)", // slate — pushback
|
||||
OFB: "rgba(148, 163, 184, 0.10)", // slate — departed gate
|
||||
TXI: "rgba(168, 162, 158, 0.10)", // stone — taxi
|
||||
DIR: "rgba(148, 163, 184, 0.08)", // slate — ready deice
|
||||
DIC: "rgba(148, 163, 184, 0.08)", // slate — deicing
|
||||
|
||||
// Departure
|
||||
TOF: "rgba(20, 184, 166, 0.14)", // teal — takeoff (emphasized)
|
||||
ICL: "rgba(20, 184, 166, 0.10)", // teal — initial climb
|
||||
TKO: "rgba(20, 184, 166, 0.08)", // teal — airborne
|
||||
|
||||
// Cruise
|
||||
ENR: "rgba(6, 126, 193, 0.06)", // blue — enroute / cruise
|
||||
|
||||
// Approach / arrival
|
||||
APR: "rgba(245, 158, 11, 0.08)", // amber — approach
|
||||
TEN: "rgba(245, 158, 11, 0.08)", // amber — approach (legacy)
|
||||
FIN: "rgba(245, 158, 11, 0.10)", // amber — on final
|
||||
LDG: "rgba(239, 68, 68, 0.10)", // red — landing (emphasized)
|
||||
LAN: "rgba(239, 68, 68, 0.08)", // red — landed
|
||||
ONB: "rgba(148, 163, 184, 0.08)", // slate — on block
|
||||
ARR: "rgba(148, 163, 184, 0.08)", // slate — arrived
|
||||
|
||||
// Non-normal
|
||||
GRT: "rgba(239, 68, 68, 0.10)", // red — ground return
|
||||
DV: "rgba(245, 158, 11, 0.12)", // amber — diverted
|
||||
EMG: "rgba(220, 38, 38, 0.16)", // red bold — emergency
|
||||
PSD: "rgba(107, 114, 128, 0.06)", // gray — paused
|
||||
};
|
||||
|
||||
const SERIES = {
|
||||
altitude: {
|
||||
label: "Altitude",
|
||||
color: "#067ec1",
|
||||
unit: "ft",
|
||||
pick: (s) => s.series.altitude.data,
|
||||
},
|
||||
speed: {
|
||||
label: "Ground speed",
|
||||
color: "#14b8a6",
|
||||
unit: "kt",
|
||||
pick: (s) => s.series.speed.gs,
|
||||
},
|
||||
fuel: {
|
||||
label: "Fuel remaining",
|
||||
color: "#f59e0b",
|
||||
unit: "lbs",
|
||||
pick: (s) => s.series.fuel.data,
|
||||
},
|
||||
vs: {
|
||||
label: "Vertical speed",
|
||||
color: "#8b5cf6",
|
||||
unit: "fpm",
|
||||
pick: (s) => s.series.vs.data,
|
||||
},
|
||||
};
|
||||
|
||||
export default function pirepPerformanceChart(payload) {
|
||||
let chartInstance = null;
|
||||
let observer = null;
|
||||
|
||||
return {
|
||||
payload,
|
||||
active: "altitude",
|
||||
|
||||
init() {
|
||||
if (!this.payload) return;
|
||||
|
||||
// Watch for canvas removal (e.g. Livewire morphing a parent).
|
||||
// Chart.js's rAF loop will throw "save on null" if the canvas
|
||||
// disappears mid-draw — stop() prevents that.
|
||||
observer = new MutationObserver(() => {
|
||||
const canvas = this.$refs.canvas;
|
||||
if (!canvas && chartInstance) {
|
||||
chartInstance.stop();
|
||||
chartInstance = null;
|
||||
}
|
||||
});
|
||||
observer.observe(this.$el.parentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
});
|
||||
|
||||
this.render();
|
||||
},
|
||||
|
||||
select(key) {
|
||||
if (key === this.active) return;
|
||||
this.active = key;
|
||||
this.render();
|
||||
},
|
||||
|
||||
/**
|
||||
* Build chartjs-plugin-annotation `box` entries for each detected flight
|
||||
* phase (climb/cruise/descent). Drawn behind the data line via
|
||||
* `drawTime: 'beforeDatasetsDraw'` so the series stays visually dominant.
|
||||
*/
|
||||
buildPhaseAnnotations() {
|
||||
const phases = this.payload?.phases ?? [];
|
||||
const annotations = {};
|
||||
|
||||
phases.forEach((phase, idx) => {
|
||||
const color = PHASE_COLORS[phase.code];
|
||||
if (!color) return;
|
||||
|
||||
annotations[`phase-${idx}`] = {
|
||||
type: "box",
|
||||
xMin: phase.start * 1000,
|
||||
xMax: phase.end * 1000,
|
||||
backgroundColor: color,
|
||||
borderWidth: 0,
|
||||
drawTime: "beforeDatasetsDraw",
|
||||
label: {
|
||||
display: idx === 0 || phases[idx - 1]?.code !== phase.code,
|
||||
content: phase.label,
|
||||
position: { x: "start", y: "start" },
|
||||
font: { family: "Geist Mono", size: 9, weight: "500" },
|
||||
color: "#6b7280",
|
||||
backgroundColor: "transparent",
|
||||
padding: { top: 4, left: 6 },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return annotations;
|
||||
},
|
||||
|
||||
render() {
|
||||
const cfg = SERIES[this.active];
|
||||
const data = cfg.pick(this.payload).filter(([, v]) => v !== null);
|
||||
|
||||
const canvas = this.$refs.canvas;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
if (chartInstance) {
|
||||
chartInstance.stop();
|
||||
|
||||
const ds = chartInstance.data.datasets[0];
|
||||
ds.label = cfg.label;
|
||||
ds.borderColor = cfg.color;
|
||||
ds.backgroundColor = `${cfg.color}22`;
|
||||
ds.data = data.map(([t, v]) => ({ x: t * 1000, y: v }));
|
||||
|
||||
chartInstance.options.scales.y.ticks.callback = (v) =>
|
||||
cfg.unit === "ft" ? (v / 1000).toFixed(0) + "k" : v.toLocaleString();
|
||||
chartInstance.options.plugins.tooltip.callbacks.label = (ctx) =>
|
||||
` ${ctx.parsed.y.toLocaleString()} ${cfg.unit}`;
|
||||
chartInstance.options.plugins.annotation.annotations = this.buildPhaseAnnotations();
|
||||
|
||||
chartInstance.update("none");
|
||||
return;
|
||||
}
|
||||
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: cfg.label,
|
||||
borderColor: cfg.color,
|
||||
backgroundColor: `${cfg.color}22`,
|
||||
data: data.map(([t, v]) => ({ x: t * 1000, y: v })),
|
||||
fill: true,
|
||||
tension: 0.25,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: false,
|
||||
animation: false,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => ` ${ctx.parsed.y.toLocaleString()} ${cfg.unit}`,
|
||||
title: (items) => new Date(items[0].parsed.x).toISOString().slice(11, 19) + "Z",
|
||||
},
|
||||
},
|
||||
annotation: {
|
||||
annotations: this.buildPhaseAnnotations(),
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
time: { unit: "minute" },
|
||||
grid: { display: false },
|
||||
ticks: { color: "#9ba3af", font: { family: "Geist Mono", size: 10 } },
|
||||
},
|
||||
y: {
|
||||
grid: { color: "#eef1f4", drawTicks: false },
|
||||
ticks: {
|
||||
color: "#9ba3af",
|
||||
font: { family: "Geist Mono", size: 10 },
|
||||
callback: (v) =>
|
||||
cfg.unit === "ft" ? (v / 1000).toFixed(0) + "k" : v.toLocaleString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
chartInstance.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
14
resources/js/admin/config.js
Normal file
14
resources/js/admin/config.js
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Reads bootstrap config (csrf, api key, base url) from meta tags.
|
||||
* Mirrors resources/js/config.js so the admin bundle stays self-contained.
|
||||
*/
|
||||
|
||||
const base_url = document.head.querySelector('meta[name="base-url"]');
|
||||
const token = document.head.querySelector('meta[name="csrf-token"]');
|
||||
const api_key = document.head.querySelector('meta[name="api-key"]');
|
||||
|
||||
export default {
|
||||
api_key: api_key?.content || "",
|
||||
base_url: base_url?.content || "",
|
||||
csrf_token: token?.content || "",
|
||||
};
|
||||
129
resources/js/admin/maps/base_map.js
Normal file
129
resources/js/admin/maps/base_map.js
Normal file
@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Builds a Leaflet map bound to opts.render_elem (DOM id).
|
||||
* Patched from resources/js/maps/base_map.js to honor opts.render_elem
|
||||
* instead of hardcoding the "map" id, so multiple maps can coexist on a page.
|
||||
*
|
||||
* Default tile stack:
|
||||
* - CartoDB Voyager (light) / CartoDB.DarkMatter (dark) — neutral base,
|
||||
* clean labels, free, no key.
|
||||
* - OpenAIP airspace + nav-aid overlay — added on top when a key is
|
||||
* present in window.filamentData.maps.openaip_api_key.
|
||||
*
|
||||
* Available providers (for base layers):
|
||||
* https://leaflet-extras.github.io/leaflet-providers/preview/
|
||||
*/
|
||||
|
||||
import leaflet from "leaflet";
|
||||
import "leaflet-providers";
|
||||
|
||||
const OPENAIP_TILE_URL =
|
||||
"https://api.tiles.openaip.net/api/data/openaip/{z}/{x}/{y}.png?apiKey={apiKey}";
|
||||
const OPENAIP_ATTRIBUTION =
|
||||
'<a href="https://www.openaip.net/" target="_blank">OpenAIP</a> — airspace data CC BY-NC-SA';
|
||||
|
||||
const TILE_PROVIDERS = {
|
||||
light: "CartoDB.Voyager",
|
||||
dark: "CartoDB.DarkMatter",
|
||||
};
|
||||
|
||||
function resolveTheme(theme) {
|
||||
if (theme === "system") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
|
||||
function addOpenAipOverlay(map) {
|
||||
const apiKey = window.filamentData?.maps?.openaip_api_key;
|
||||
if (!apiKey) return;
|
||||
|
||||
leaflet
|
||||
.tileLayer(OPENAIP_TILE_URL, {
|
||||
apiKey,
|
||||
attribution: OPENAIP_ATTRIBUTION,
|
||||
maxZoom: 14,
|
||||
minZoom: 4,
|
||||
opacity: 0.9,
|
||||
})
|
||||
.addTo(map);
|
||||
}
|
||||
|
||||
export default (_opts) => {
|
||||
const opts = Object.assign(
|
||||
{
|
||||
render_elem: "map",
|
||||
center: [29.98139, -95.33374],
|
||||
zoom: 5,
|
||||
maxZoom: 14,
|
||||
layers: [],
|
||||
set_marker: false,
|
||||
leafletOptions: {},
|
||||
},
|
||||
_opts,
|
||||
);
|
||||
|
||||
const leafletOptions = Object.assign(
|
||||
{
|
||||
center: opts.center,
|
||||
zoom: opts.zoom,
|
||||
scrollWheelZoom: false,
|
||||
providers: {},
|
||||
},
|
||||
opts.leafletOptions,
|
||||
);
|
||||
|
||||
// Default tile provider if caller didn't specify one.
|
||||
const hasCustomProvider = Object.entries(leafletOptions.providers).length > 0;
|
||||
if (!hasCustomProvider) {
|
||||
const initialTheme =
|
||||
window.Alpine?.store("theme") ??
|
||||
(document.documentElement.classList.contains("dark") ? "dark" : "light");
|
||||
leafletOptions.providers = {
|
||||
[TILE_PROVIDERS[resolveTheme(initialTheme)]]: {},
|
||||
};
|
||||
}
|
||||
|
||||
const map = leaflet.map(opts.render_elem, leafletOptions);
|
||||
|
||||
// eslint-disable-next-line guard-for-in,no-restricted-syntax
|
||||
for (const key in leafletOptions.providers) {
|
||||
leaflet.tileLayer.provider(key, leafletOptions.providers[key]).addTo(map);
|
||||
}
|
||||
|
||||
addOpenAipOverlay(map);
|
||||
|
||||
// Swap base tile layer when Filament's theme changes.
|
||||
// Only applies when we own the provider (no custom leafletOptions.providers).
|
||||
if (!hasCustomProvider) {
|
||||
let baseTileLayer = null;
|
||||
let openAipLayer = null;
|
||||
// Grab the tile layers we added.
|
||||
map.eachLayer((layer) => {
|
||||
if (layer instanceof leaflet.TileLayer) {
|
||||
if (!baseTileLayer) {
|
||||
baseTileLayer = layer;
|
||||
} else {
|
||||
openAipLayer = layer;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("theme-changed", (event) => {
|
||||
const theme = resolveTheme(event.detail);
|
||||
const newProvider = TILE_PROVIDERS[theme];
|
||||
if (!newProvider || !baseTileLayer) return;
|
||||
|
||||
const newTileLayer = leaflet.tileLayer.provider(newProvider);
|
||||
map.removeLayer(baseTileLayer);
|
||||
newTileLayer.addTo(map);
|
||||
// Re-add OpenAIP overlay on top if it exists.
|
||||
if (openAipLayer) {
|
||||
map.removeLayer(openAipLayer);
|
||||
openAipLayer.addTo(map);
|
||||
}
|
||||
baseTileLayer = newTileLayer;
|
||||
});
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
7
resources/js/admin/maps/config.js
Normal file
7
resources/js/admin/maps/config.js
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Shared route/marker colors for admin maps.
|
||||
*/
|
||||
|
||||
export const PLAN_ROUTE_COLOR = "#8B008B";
|
||||
export const ACTUAL_ROUTE_COLOR = "#067ec1";
|
||||
export const CIRCLE_COLOR = "#056093";
|
||||
44
resources/js/admin/maps/helpers.js
Normal file
44
resources/js/admin/maps/helpers.js
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Helpers shared between admin map renderers.
|
||||
*/
|
||||
|
||||
import leaflet from "leaflet";
|
||||
|
||||
/**
|
||||
* Add a WMS layer to a map.
|
||||
*
|
||||
* @param {*} map Leaflet map instance
|
||||
* @param {{ url: string, params: Object }} opts
|
||||
*/
|
||||
export function addWMSLayer(map, opts) {
|
||||
if (opts.url === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
opts.params = Object.assign(
|
||||
{
|
||||
format: "image/png",
|
||||
transparent: true,
|
||||
maxZoom: 14,
|
||||
minZoom: 4,
|
||||
},
|
||||
opts.params,
|
||||
);
|
||||
|
||||
const mlayer = leaflet.tileLayer.wms(opts.url, opts.params);
|
||||
mlayer.addTo(map);
|
||||
|
||||
return mlayer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a popup to a feature layer if the feature has popup HTML.
|
||||
*/
|
||||
export function showFeaturePopup(feature, layer) {
|
||||
let popup_html = "";
|
||||
if (feature.properties && feature.properties.popup) {
|
||||
popup_html += feature.properties.popup;
|
||||
}
|
||||
|
||||
layer.bindPopup(popup_html);
|
||||
}
|
||||
17
resources/js/admin/maps/index.js
Normal file
17
resources/js/admin/maps/index.js
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Admin maps barrel.
|
||||
* Exposes leaflet globally (L) so geodesic / rotatedmarker plugins can extend it,
|
||||
* then re-exports the renderers used by admin blades.
|
||||
*/
|
||||
|
||||
import L from "leaflet";
|
||||
|
||||
import "leaflet.geodesic";
|
||||
import "leaflet-rotatedmarker";
|
||||
|
||||
import render_route_map from "./route_map";
|
||||
import render_base_map from "./base_map";
|
||||
|
||||
window.L = L;
|
||||
|
||||
export { render_route_map, render_base_map };
|
||||
132
resources/js/admin/maps/route_map.js
Normal file
132
resources/js/admin/maps/route_map.js
Normal file
@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Renders a PIREP route map (planned + actual route lines and points).
|
||||
*
|
||||
* Mirrors resources/js/maps/route_map.js but lives in the admin tree so the
|
||||
* admin bundle can evolve independently from the seven theme bundle.
|
||||
*/
|
||||
|
||||
import leaflet from "leaflet";
|
||||
|
||||
import draw_base_map from "./base_map";
|
||||
import { addWMSLayer } from "./helpers";
|
||||
import { ACTUAL_ROUTE_COLOR, CIRCLE_COLOR, PLAN_ROUTE_COLOR } from "./config";
|
||||
|
||||
/**
|
||||
* Bind a popup to each route point feature when it has popup HTML.
|
||||
*/
|
||||
export const onFeaturePointClick = (feature, layer) => {
|
||||
let popup_html = "";
|
||||
if (feature.properties && feature.properties.popup) {
|
||||
popup_html += feature.properties.popup;
|
||||
}
|
||||
|
||||
layer.bindPopup(popup_html);
|
||||
};
|
||||
|
||||
export default (_opts) => {
|
||||
const opts = Object.assign(
|
||||
{
|
||||
route_points: null,
|
||||
planned_route_line: null,
|
||||
actual_route_points: null,
|
||||
actual_route_line: null,
|
||||
render_elem: "map",
|
||||
live_map: false,
|
||||
aircraft_icon: "/assets/img/acars/aircraft.png",
|
||||
refresh_interval: 10,
|
||||
flown_route_color: ACTUAL_ROUTE_COLOR,
|
||||
circle_color: CIRCLE_COLOR,
|
||||
flightplan_route_color: PLAN_ROUTE_COLOR,
|
||||
metar_wms: {
|
||||
url: "",
|
||||
params: {},
|
||||
},
|
||||
},
|
||||
_opts,
|
||||
);
|
||||
|
||||
const pointToLayer = (feature, latlng) =>
|
||||
leaflet.circleMarker(latlng, {
|
||||
radius: 5,
|
||||
fillColor: opts.circle_color,
|
||||
color: "#000",
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8,
|
||||
});
|
||||
|
||||
const map = draw_base_map(opts);
|
||||
|
||||
if (opts.metar_wms.url !== "") {
|
||||
addWMSLayer(map, opts.metar_wms);
|
||||
}
|
||||
|
||||
// Planned route line (great-circle).
|
||||
const plannedRouteLayer = new L.Geodesic([], {
|
||||
weight: 4,
|
||||
opacity: 0.9,
|
||||
color: opts.flightplan_route_color,
|
||||
steps: 50,
|
||||
wrap: false,
|
||||
}).addTo(map);
|
||||
|
||||
if (opts.planned_route_line) {
|
||||
plannedRouteLayer.fromGeoJson(opts.planned_route_line);
|
||||
|
||||
try {
|
||||
map.fitBounds(plannedRouteLayer.getBounds());
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Planned route waypoints.
|
||||
if (opts.route_points !== null) {
|
||||
const route_points = leaflet.geoJSON(opts.route_points, {
|
||||
onEachFeature: onFeaturePointClick,
|
||||
pointToLayer,
|
||||
style: {
|
||||
color: opts.flightplan_route_color,
|
||||
weight: 3,
|
||||
opacity: 0.65,
|
||||
},
|
||||
});
|
||||
|
||||
route_points.addTo(map);
|
||||
}
|
||||
|
||||
// Actual flown route.
|
||||
if (opts.actual_route_line !== null && opts.actual_route_line.features.length > 0) {
|
||||
const actualRouteLayer = new L.Geodesic([], {
|
||||
weight: 3,
|
||||
opacity: 0.9,
|
||||
color: opts.flown_route_color,
|
||||
steps: 50,
|
||||
wrap: false,
|
||||
}).addTo(map);
|
||||
|
||||
actualRouteLayer.fromGeoJson(opts.actual_route_line);
|
||||
|
||||
try {
|
||||
map.fitBounds(actualRouteLayer.getBounds());
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.actual_route_points !== null && opts.actual_route_points.features.length > 0) {
|
||||
const route_points = leaflet.geoJSON(opts.actual_route_points, {
|
||||
onEachFeature: onFeaturePointClick,
|
||||
pointToLayer,
|
||||
style: {
|
||||
color: opts.flown_route_color,
|
||||
weight: 3,
|
||||
opacity: 0.65,
|
||||
},
|
||||
});
|
||||
|
||||
route_points.addTo(map);
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
35
resources/js/admin/request.js
Normal file
35
resources/js/admin/request.js
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Axios-based request helper for the admin bundle.
|
||||
* Mirrors resources/js/request.js.
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
import config from "./config";
|
||||
|
||||
/**
|
||||
* Run an API request with phpvms defaults applied.
|
||||
*
|
||||
* @param {Object|String} _opts Axios request options, or a URL string
|
||||
* @param {String} _opts.url
|
||||
*/
|
||||
export default async (_opts) => {
|
||||
if (typeof _opts === "string" || _opts instanceof String) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
_opts = { url: _opts };
|
||||
}
|
||||
|
||||
const opts = Object.assign(
|
||||
{},
|
||||
{
|
||||
baseURL: config.base_url,
|
||||
headers: {
|
||||
"X-API-KEY": config.api_key,
|
||||
"X-CSRF-TOKEN": config.csrf_token,
|
||||
},
|
||||
},
|
||||
_opts,
|
||||
);
|
||||
|
||||
return axios.request(opts);
|
||||
};
|
||||
51
resources/js/admin/storage.js
Normal file
51
resources/js/admin/storage.js
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Simple localStorage wrapper.
|
||||
* Mirrors resources/js/storage.js.
|
||||
*/
|
||||
|
||||
export default class Storage {
|
||||
constructor(name, default_value) {
|
||||
this.name = name;
|
||||
|
||||
const st = window.localStorage.getItem(this.name);
|
||||
if (!st) {
|
||||
this.data = default_value;
|
||||
} else {
|
||||
this.data = JSON.parse(st);
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
window.localStorage.setItem(this.name, JSON.stringify(this.data));
|
||||
}
|
||||
|
||||
getList(key) {
|
||||
if (!(key in this.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.data[key];
|
||||
}
|
||||
|
||||
addToList(key, value) {
|
||||
if (!(key in this.data)) {
|
||||
this.data[key] = [];
|
||||
}
|
||||
|
||||
const index = this.data[key].indexOf(value);
|
||||
if (index === -1) {
|
||||
this.data[key].push(value);
|
||||
}
|
||||
}
|
||||
|
||||
removeFromList(key, value) {
|
||||
if (!(key in this.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this.data[key].indexOf(value);
|
||||
if (index !== -1) {
|
||||
this.data[key].splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Before you edit these, read the documentation on how these files are compiled:
|
||||
* https://docs.phpvms.net/developers/building-assets
|
||||
*
|
||||
* Edits here don't take place until you compile these assets and then upload them.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Admin stuff needed
|
||||
*/
|
||||
|
||||
import "../entrypoint";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user