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:
Nabeel S. 2026-05-20 11:05:32 -05:00 committed by GitHub
parent 8dfba75b0f
commit de34422008
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
211 changed files with 15040 additions and 1978 deletions

View File

@ -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

View File

@ -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
View File

@ -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

View File

@ -3,6 +3,7 @@
"ignorePatterns": [
"public/**",
"modules/**",
"resources/js/dist/**",
".github/skills/*",
".github/copilot-instructions.md"
]

View File

@ -36,5 +36,5 @@
"builtin": true
},
"globals": {},
"ignorePatterns": ["public/**"]
"ignorePatterns": ["public/**", "resources/js/dist/**"]
}

1
.php-cs-fixer.cache Normal file
View 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
View 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
View 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
}

View File

@ -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'),
};
}

View File

@ -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()

View 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
{
//
}
}

View File

@ -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())

View 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
{
//
}
}

View File

@ -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)')

View File

@ -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');

View File

@ -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;
}

View File

@ -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(),
];
}
}

View File

@ -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 [

View File

@ -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')

View File

@ -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')

View File

@ -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')

View File

@ -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

View File

@ -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(),

View 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');
}
}

View 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));
}
}),
];
}
}

View 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),
]);
}
}

View 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(),
]),
]);
}
}

View File

@ -19,7 +19,7 @@ class PageForm
return $schema
->components([
Section::make(__('filament.page_informations'))
Section::make(__('filament.page_information'))
->schema([
Grid::make()
->schema([

View File

@ -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();
}
}

View File

@ -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)),
];
}
}

View 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;
}
}
}

View File

@ -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'),
];
}

View File

@ -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)),

View File

@ -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')),

View File

@ -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')),

View File

@ -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([
//

View File

@ -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),

View File

@ -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([

View File

@ -16,7 +16,7 @@ class RankForm
return $schema
->components([
Section::make(__('filament.rank_informations'))
Section::make(__('filament.rank_information'))
->schema([
Grid::make()
->schema([

View File

@ -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

View File

@ -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'))

View File

@ -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(),

View File

@ -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')

View File

@ -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(),

View File

@ -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')

View File

@ -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
{

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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));
}
}),
]);
}
}

View File

@ -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');
}

View 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;
}
}

View 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),
]);
}
}

View 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');
}
}

View 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');
}
}

View File

@ -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));
/**

View File

@ -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'),
],
]);
}
}

View File

@ -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();
}
}

View File

@ -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);
});
}

View 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 0100
// 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 0100.
*/
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 26° 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;
}
}

View File

@ -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
View 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",
});

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -40,11 +40,6 @@ return [
'report' => false,
],
'seeds' => [
'driver' => 'local',
'root' => database_path('seeders'),
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),

View File

@ -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,
],

View File

@ -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),
],
/**

View File

@ -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', ''),
],
];

View File

@ -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);

View File

@ -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
View 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
View 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
View File

@ -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": {

View File

@ -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",

View 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

View File

@ -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};

View File

@ -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
View 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 }));

View 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 0100 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,
};
},
};
}

View 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();
},
};
}

View 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 || "",
};

View 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;
};

View 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";

View 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);
}

View 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 };

View 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;
};

View 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);
};

View 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);
}
}
}

View File

@ -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