Compare commits
1 Commits
master
...
6d22830f95
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d22830f95 |
129
.agents/skills/tailwindcss-development/SKILL.md
Normal file
129
.agents/skills/tailwindcss-development/SKILL.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
name: tailwindcss-development
|
||||||
|
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tailwind CSS Development
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Activate this skill when:
|
||||||
|
|
||||||
|
- Adding styles to components or pages
|
||||||
|
- Working with responsive design
|
||||||
|
- Implementing dark mode
|
||||||
|
- Extracting repeated patterns into components
|
||||||
|
- Debugging spacing or layout issues
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||||
|
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||||
|
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||||
|
|
||||||
|
## Tailwind CSS v4 Specifics
|
||||||
|
|
||||||
|
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||||
|
- `corePlugins` is not supported in Tailwind v4.
|
||||||
|
|
||||||
|
### CSS-First Configuration
|
||||||
|
|
||||||
|
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||||
|
|
||||||
|
<!-- CSS-First Config -->
|
||||||
|
```css
|
||||||
|
@theme {
|
||||||
|
--color-brand: oklch(0.72 0.11 178);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Syntax
|
||||||
|
|
||||||
|
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||||
|
|
||||||
|
<!-- v4 Import Syntax -->
|
||||||
|
```diff
|
||||||
|
- @tailwind base;
|
||||||
|
- @tailwind components;
|
||||||
|
- @tailwind utilities;
|
||||||
|
+ @import "tailwindcss";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replaced Utilities
|
||||||
|
|
||||||
|
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||||
|
|
||||||
|
| Deprecated | Replacement |
|
||||||
|
|------------|-------------|
|
||||||
|
| bg-opacity-* | bg-black/* |
|
||||||
|
| text-opacity-* | text-black/* |
|
||||||
|
| border-opacity-* | border-black/* |
|
||||||
|
| divide-opacity-* | divide-black/* |
|
||||||
|
| ring-opacity-* | ring-black/* |
|
||||||
|
| placeholder-opacity-* | placeholder-black/* |
|
||||||
|
| flex-shrink-* | shrink-* |
|
||||||
|
| flex-grow-* | grow-* |
|
||||||
|
| overflow-ellipsis | text-ellipsis |
|
||||||
|
| decoration-slice | box-decoration-slice |
|
||||||
|
| decoration-clone | box-decoration-clone |
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
Use `gap` utilities instead of margins for spacing between siblings:
|
||||||
|
|
||||||
|
<!-- Gap Utilities -->
|
||||||
|
```html
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div>Item 1</div>
|
||||||
|
<div>Item 2</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||||
|
|
||||||
|
<!-- Dark Mode -->
|
||||||
|
```html
|
||||||
|
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||||
|
Content adapts to color scheme
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Flexbox Layout
|
||||||
|
|
||||||
|
<!-- Flexbox Layout -->
|
||||||
|
```html
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>Left content</div>
|
||||||
|
<div>Right content</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid Layout
|
||||||
|
|
||||||
|
<!-- Grid Layout -->
|
||||||
|
```html
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div>Card 1</div>
|
||||||
|
<div>Card 2</div>
|
||||||
|
<div>Card 3</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||||
|
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||||
|
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||||
|
- Using margins for spacing between siblings instead of gap utilities
|
||||||
|
- Forgetting to add dark mode variants when the project uses dark mode
|
||||||
129
.claude/skills/tailwindcss-development/SKILL.md
Normal file
129
.claude/skills/tailwindcss-development/SKILL.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
name: tailwindcss-development
|
||||||
|
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tailwind CSS Development
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Activate this skill when:
|
||||||
|
|
||||||
|
- Adding styles to components or pages
|
||||||
|
- Working with responsive design
|
||||||
|
- Implementing dark mode
|
||||||
|
- Extracting repeated patterns into components
|
||||||
|
- Debugging spacing or layout issues
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||||
|
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||||
|
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||||
|
|
||||||
|
## Tailwind CSS v4 Specifics
|
||||||
|
|
||||||
|
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||||
|
- `corePlugins` is not supported in Tailwind v4.
|
||||||
|
|
||||||
|
### CSS-First Configuration
|
||||||
|
|
||||||
|
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||||
|
|
||||||
|
<!-- CSS-First Config -->
|
||||||
|
```css
|
||||||
|
@theme {
|
||||||
|
--color-brand: oklch(0.72 0.11 178);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Syntax
|
||||||
|
|
||||||
|
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||||
|
|
||||||
|
<!-- v4 Import Syntax -->
|
||||||
|
```diff
|
||||||
|
- @tailwind base;
|
||||||
|
- @tailwind components;
|
||||||
|
- @tailwind utilities;
|
||||||
|
+ @import "tailwindcss";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replaced Utilities
|
||||||
|
|
||||||
|
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||||
|
|
||||||
|
| Deprecated | Replacement |
|
||||||
|
|------------|-------------|
|
||||||
|
| bg-opacity-* | bg-black/* |
|
||||||
|
| text-opacity-* | text-black/* |
|
||||||
|
| border-opacity-* | border-black/* |
|
||||||
|
| divide-opacity-* | divide-black/* |
|
||||||
|
| ring-opacity-* | ring-black/* |
|
||||||
|
| placeholder-opacity-* | placeholder-black/* |
|
||||||
|
| flex-shrink-* | shrink-* |
|
||||||
|
| flex-grow-* | grow-* |
|
||||||
|
| overflow-ellipsis | text-ellipsis |
|
||||||
|
| decoration-slice | box-decoration-slice |
|
||||||
|
| decoration-clone | box-decoration-clone |
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
Use `gap` utilities instead of margins for spacing between siblings:
|
||||||
|
|
||||||
|
<!-- Gap Utilities -->
|
||||||
|
```html
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div>Item 1</div>
|
||||||
|
<div>Item 2</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||||
|
|
||||||
|
<!-- Dark Mode -->
|
||||||
|
```html
|
||||||
|
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||||
|
Content adapts to color scheme
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Flexbox Layout
|
||||||
|
|
||||||
|
<!-- Flexbox Layout -->
|
||||||
|
```html
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>Left content</div>
|
||||||
|
<div>Right content</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid Layout
|
||||||
|
|
||||||
|
<!-- Grid Layout -->
|
||||||
|
```html
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div>Card 1</div>
|
||||||
|
<div>Card 2</div>
|
||||||
|
<div>Card 3</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||||
|
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||||
|
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||||
|
- Using margins for spacing between siblings instead of gap utilities
|
||||||
|
- Forgetting to add dark mode variants when the project uses dark mode
|
||||||
@@ -63,10 +63,3 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
LDAP_HOST=openldap
|
|
||||||
LDAP_USERNAME="cn=admin,dc=travel,dc=local"
|
|
||||||
LDAP_PASSWORD=adminpassword
|
|
||||||
LDAP_PORT=389
|
|
||||||
LDAP_BASE_DN="dc=travel,dc=local"
|
|
||||||
LDAP_LOGGING=true
|
|
||||||
|
|||||||
20
AGENTS.md
20
AGENTS.md
@@ -9,24 +9,25 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
|||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.4.1
|
- php - 8.5.3
|
||||||
- filament/filament (FILAMENT) - v5
|
- filament/filament (FILAMENT) - v5
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
- livewire/livewire (LIVEWIRE) - v4
|
- livewire/livewire (LIVEWIRE) - v4
|
||||||
- larastan/larastan (LARASTAN) - v3
|
|
||||||
- laravel/boost (BOOST) - v2
|
- laravel/boost (BOOST) - v2
|
||||||
- laravel/mcp (MCP) - v0
|
- laravel/mcp (MCP) - v0
|
||||||
- laravel/pail (PAIL) - v1
|
- laravel/pail (PAIL) - v1
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/sail (SAIL) - v1
|
- laravel/sail (SAIL) - v1
|
||||||
- phpunit/phpunit (PHPUNIT) - v11
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
|
||||||
## Skills Activation
|
## Skills Activation
|
||||||
|
|
||||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||||
|
|
||||||
- `livewire-development` — Develops reactive Livewire 4 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI.
|
- `livewire-development` — Develops reactive Livewire 4 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI.
|
||||||
|
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -132,13 +133,6 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
|||||||
|
|
||||||
- Add useful array shape type definitions when appropriate.
|
- Add useful array shape type definitions when appropriate.
|
||||||
|
|
||||||
=== tests rules ===
|
|
||||||
|
|
||||||
# Test Enforcement
|
|
||||||
|
|
||||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
|
||||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
# Do Things the Laravel Way
|
# Do Things the Laravel Way
|
||||||
@@ -253,4 +247,12 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
|||||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||||
|
|
||||||
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
|
# Tailwind CSS
|
||||||
|
|
||||||
|
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||||
|
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||||
|
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||||
|
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|||||||
20
CLAUDE.md
20
CLAUDE.md
@@ -9,24 +9,25 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
|||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.4.1
|
- php - 8.5.3
|
||||||
- filament/filament (FILAMENT) - v5
|
- filament/filament (FILAMENT) - v5
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
- livewire/livewire (LIVEWIRE) - v4
|
- livewire/livewire (LIVEWIRE) - v4
|
||||||
- larastan/larastan (LARASTAN) - v3
|
|
||||||
- laravel/boost (BOOST) - v2
|
- laravel/boost (BOOST) - v2
|
||||||
- laravel/mcp (MCP) - v0
|
- laravel/mcp (MCP) - v0
|
||||||
- laravel/pail (PAIL) - v1
|
- laravel/pail (PAIL) - v1
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/sail (SAIL) - v1
|
- laravel/sail (SAIL) - v1
|
||||||
- phpunit/phpunit (PHPUNIT) - v11
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
|
||||||
## Skills Activation
|
## Skills Activation
|
||||||
|
|
||||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||||
|
|
||||||
- `livewire-development` — Develops reactive Livewire 4 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI.
|
- `livewire-development` — Develops reactive Livewire 4 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI.
|
||||||
|
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -132,13 +133,6 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
|||||||
|
|
||||||
- Add useful array shape type definitions when appropriate.
|
- Add useful array shape type definitions when appropriate.
|
||||||
|
|
||||||
=== tests rules ===
|
|
||||||
|
|
||||||
# Test Enforcement
|
|
||||||
|
|
||||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
|
||||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
# Do Things the Laravel Way
|
# Do Things the Laravel Way
|
||||||
@@ -253,4 +247,12 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
|||||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||||
|
|
||||||
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
|
# Tailwind CSS
|
||||||
|
|
||||||
|
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||||
|
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||||
|
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||||
|
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum AustralianState: int
|
|
||||||
{
|
|
||||||
case NSW = 1;
|
|
||||||
case VIC = 2;
|
|
||||||
case QLD = 3;
|
|
||||||
case SA = 4;
|
|
||||||
case WA = 5;
|
|
||||||
case TAS = 6;
|
|
||||||
case NT = 7;
|
|
||||||
case ACT = 8;
|
|
||||||
|
|
||||||
public function label(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::NSW => 'New South Wales',
|
|
||||||
self::VIC => 'Victoria',
|
|
||||||
self::QLD => 'Queensland',
|
|
||||||
self::SA => 'South Australia',
|
|
||||||
self::WA => 'Western Australia',
|
|
||||||
self::TAS => 'Tasmania',
|
|
||||||
self::NT => 'Northern Territory',
|
|
||||||
self::ACT => 'Australian Capital Territory',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function abbreviation(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::NSW => 'NSW',
|
|
||||||
self::VIC => 'VIC',
|
|
||||||
self::QLD => 'QLD',
|
|
||||||
self::SA => 'SA',
|
|
||||||
self::WA => 'WA',
|
|
||||||
self::TAS => 'TAS',
|
|
||||||
self::NT => 'NT',
|
|
||||||
self::ACT => 'ACT',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\Town;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class SyncTowns implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
private const API_URL = 'https://services-ap1.arcgis.com/ypkPEy1AmwPKGNNv/arcgis/rest/services/Town_Point/FeatureServer/0/query';
|
|
||||||
|
|
||||||
private const PAGE_SIZE = 1000;
|
|
||||||
|
|
||||||
public int $timeout = 300;
|
|
||||||
|
|
||||||
public function handle(): void
|
|
||||||
{
|
|
||||||
$offset = 0;
|
|
||||||
$synced = 0;
|
|
||||||
|
|
||||||
do {
|
|
||||||
$response = Http::timeout(30)->get(self::API_URL, [
|
|
||||||
'outFields' => 'town_pid,town_name,state,population,town_class,date_retired',
|
|
||||||
'where' => '1=1',
|
|
||||||
'f' => 'json',
|
|
||||||
'resultOffset' => $offset,
|
|
||||||
'resultRecordCount' => self::PAGE_SIZE,
|
|
||||||
'orderByFields' => 'objectid ASC',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->failed()) {
|
|
||||||
Log::error('SyncTowns: API request failed', ['offset' => $offset, 'status' => $response->status()]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$features = $response->json('features', []);
|
|
||||||
|
|
||||||
if (empty($features)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$records = collect($features)->map(fn (array $feature) => [
|
|
||||||
'town_pid' => $feature['attributes']['town_pid'],
|
|
||||||
'town_name' => $feature['attributes']['town_name'],
|
|
||||||
'state' => $feature['attributes']['state'],
|
|
||||||
'population' => $feature['attributes']['population'],
|
|
||||||
'town_class' => $feature['attributes']['town_class'],
|
|
||||||
'date_retired' => isset($feature['attributes']['date_retired'])
|
|
||||||
? Carbon::createFromTimestampMs($feature['attributes']['date_retired'])
|
|
||||||
: null,
|
|
||||||
'updated_at' => now(),
|
|
||||||
'created_at' => now(),
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
Town::upsert($records, uniqueBy: ['town_pid'], update: [
|
|
||||||
'town_name',
|
|
||||||
'state',
|
|
||||||
'population',
|
|
||||||
'town_class',
|
|
||||||
'date_retired',
|
|
||||||
'updated_at',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$synced += count($features);
|
|
||||||
$offset += self::PAGE_SIZE;
|
|
||||||
} while (count($features) === self::PAGE_SIZE);
|
|
||||||
|
|
||||||
Log::info("SyncTowns: synced {$synced} towns.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Enums\AustralianState;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class Town extends Model
|
|
||||||
{
|
|
||||||
/** @use HasFactory<\Database\Factories\TownFactory> */
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'town_pid',
|
|
||||||
'town_name',
|
|
||||||
'state',
|
|
||||||
'population',
|
|
||||||
'town_class',
|
|
||||||
'date_retired',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'state' => AustralianState::class,
|
|
||||||
'date_retired' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeActive(Builder $query): void
|
|
||||||
{
|
|
||||||
$query->whereNull('date_retired');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeSearch(Builder $query, string $term): void
|
|
||||||
{
|
|
||||||
$query->where('town_name', 'like', $term.'%');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -64,16 +64,6 @@ class User extends Authenticatable implements FilamentUser, LdapAuthenticatable
|
|||||||
return $this->hasRole('administrator');
|
return $this->hasRole('administrator');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getLdapGuidColumn(): string
|
|
||||||
{
|
|
||||||
return 'ldap_guid';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLdapDomainColumn(): string
|
|
||||||
{
|
|
||||||
return 'ldap_domain';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function emergencyContacts(): HasMany
|
public function emergencyContacts(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(EmergencyContact::class);
|
return $this->hasMany(EmergencyContact::class);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ApprovalService
|
|||||||
$workflow = $travelRequest->workflow;
|
$workflow = $travelRequest->workflow;
|
||||||
|
|
||||||
if (! $workflow) {
|
if (! $workflow) {
|
||||||
throw new \RuntimeException('No active approval workflow is configured.');
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$steps = $workflow->steps()->orderBy('order')->get();
|
$steps = $workflow->steps()->orderBy('order')->get();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"nightwatch_mcp": false,
|
"nightwatch_mcp": false,
|
||||||
"sail": false,
|
"sail": false,
|
||||||
"skills": [
|
"skills": [
|
||||||
"livewire-development"
|
"livewire-development",
|
||||||
|
"tailwindcss-development"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ services:
|
|||||||
- sail
|
- sail
|
||||||
openldap:
|
openldap:
|
||||||
image: 'osixia/openldap:1.5.0'
|
image: 'osixia/openldap:1.5.0'
|
||||||
command: '--copy-service'
|
|
||||||
ports:
|
ports:
|
||||||
- '${FORWARD_LDAP_PORT:-389}:389'
|
- '${FORWARD_LDAP_PORT:-389}:389'
|
||||||
- '${FORWARD_LDAPS_PORT:-636}:636'
|
- '${FORWARD_LDAPS_PORT:-636}:636'
|
||||||
@@ -97,7 +96,8 @@ services:
|
|||||||
LDAP_READONLY_USER_USERNAME: '${LDAP_READONLY_USERNAME:-readonly}'
|
LDAP_READONLY_USER_USERNAME: '${LDAP_READONLY_USERNAME:-readonly}'
|
||||||
LDAP_READONLY_USER_PASSWORD: '${LDAP_READONLY_PASSWORD:-readonly}'
|
LDAP_READONLY_USER_PASSWORD: '${LDAP_READONLY_PASSWORD:-readonly}'
|
||||||
volumes:
|
volumes:
|
||||||
- './docker/openldap/bootstrap.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/bootstrap.ldif'
|
- 'sail-ldap-data:/var/lib/ldap'
|
||||||
|
- 'sail-ldap-config:/etc/ldap/slapd.d'
|
||||||
networks:
|
networks:
|
||||||
- sail
|
- sail
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -134,3 +134,7 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
sail-redis:
|
sail-redis:
|
||||||
driver: local
|
driver: local
|
||||||
|
sail-ldap-data:
|
||||||
|
driver: local
|
||||||
|
sail-ldap-config:
|
||||||
|
driver: local
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Town>
|
|
||||||
*/
|
|
||||||
class TownFactory extends Factory
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Define the model's default state.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'town_pid' => 'TWN'.$this->faker->unique()->numerify('####'),
|
|
||||||
'town_name' => $this->faker->city(),
|
|
||||||
'state' => $this->faker->numberBetween(1, 8),
|
|
||||||
'population' => $this->faker->numberBetween(100, 500000),
|
|
||||||
'town_class' => $this->faker->numberBetween(0, 5),
|
|
||||||
'date_retired' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function retired(): static
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes) => [
|
|
||||||
'date_retired' => now()->subDays(30),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function inState(\App\Enums\AustralianState $state): static
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes) => [
|
|
||||||
'state' => $state->value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('towns', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->string('town_pid', 15)->unique();
|
|
||||||
$table->string('town_name', 50);
|
|
||||||
$table->unsignedTinyInteger('state');
|
|
||||||
$table->unsignedInteger('population')->nullable();
|
|
||||||
$table->unsignedTinyInteger('town_class')->nullable();
|
|
||||||
$table->timestamp('date_retired')->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index(['town_name', 'state']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('towns');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -16,28 +16,28 @@ class DatabaseSeeder extends Seeder
|
|||||||
// Create roles
|
// Create roles
|
||||||
$this->call(RoleSeeder::class);
|
$this->call(RoleSeeder::class);
|
||||||
|
|
||||||
// Create admin user — email matches LDAP mail attribute
|
// Create admin user
|
||||||
$admin = User::factory()->create([
|
$admin = User::factory()->create([
|
||||||
'name' => 'Administrator',
|
'name' => 'Administrator',
|
||||||
'email' => 'admin@travel.local',
|
'email' => 'admin@example.com',
|
||||||
'username' => 'admin',
|
'username' => 'admin',
|
||||||
'password' => Hash::make('password'),
|
'password' => Hash::make('password'),
|
||||||
]);
|
]);
|
||||||
$admin->assignRole('administrator');
|
$admin->assignRole('administrator');
|
||||||
|
|
||||||
// Create a travel approver — email matches LDAP mail attribute
|
// Create a travel approver
|
||||||
$approver = User::factory()->create([
|
$approver = User::factory()->create([
|
||||||
'name' => 'Travel Approver',
|
'name' => 'Travel Approver',
|
||||||
'email' => 'approver@travel.local',
|
'email' => 'approver@example.com',
|
||||||
'username' => 'approver',
|
'username' => 'approver',
|
||||||
'password' => Hash::make('password'),
|
'password' => Hash::make('password'),
|
||||||
]);
|
]);
|
||||||
$approver->assignRole('travel_approver');
|
$approver->assignRole('travel_approver');
|
||||||
|
|
||||||
// Create a staff user — email matches LDAP mail attribute
|
// Create a staff user
|
||||||
$staff = User::factory()->create([
|
$staff = User::factory()->create([
|
||||||
'name' => 'Staff Member',
|
'name' => 'Staff Member',
|
||||||
'email' => 'staff@travel.local',
|
'email' => 'staff@example.com',
|
||||||
'username' => 'staff',
|
'username' => 'staff',
|
||||||
'password' => Hash::make('password'),
|
'password' => Hash::make('password'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# People OU
|
|
||||||
dn: ou=people,dc=travel,dc=local
|
|
||||||
objectClass: organizationalUnit
|
|
||||||
ou: people
|
|
||||||
|
|
||||||
# Administrator
|
|
||||||
dn: uid=admin,ou=people,dc=travel,dc=local
|
|
||||||
objectClass: inetOrgPerson
|
|
||||||
objectClass: posixAccount
|
|
||||||
objectClass: shadowAccount
|
|
||||||
cn: Administrator
|
|
||||||
sn: Administrator
|
|
||||||
uid: admin
|
|
||||||
mail: admin@travel.local
|
|
||||||
uidNumber: 1000
|
|
||||||
gidNumber: 1000
|
|
||||||
homeDirectory: /home/admin
|
|
||||||
userPassword: password
|
|
||||||
|
|
||||||
# Travel Approver
|
|
||||||
dn: uid=approver,ou=people,dc=travel,dc=local
|
|
||||||
objectClass: inetOrgPerson
|
|
||||||
objectClass: posixAccount
|
|
||||||
objectClass: shadowAccount
|
|
||||||
cn: Travel Approver
|
|
||||||
sn: Approver
|
|
||||||
uid: approver
|
|
||||||
mail: approver@travel.local
|
|
||||||
uidNumber: 1001
|
|
||||||
gidNumber: 1000
|
|
||||||
homeDirectory: /home/approver
|
|
||||||
userPassword: password
|
|
||||||
|
|
||||||
# Staff Member
|
|
||||||
dn: uid=staff,ou=people,dc=travel,dc=local
|
|
||||||
objectClass: inetOrgPerson
|
|
||||||
objectClass: posixAccount
|
|
||||||
objectClass: shadowAccount
|
|
||||||
cn: Staff Member
|
|
||||||
sn: Member
|
|
||||||
uid: staff
|
|
||||||
mail: staff@travel.local
|
|
||||||
uidNumber: 1002
|
|
||||||
gidNumber: 1000
|
|
||||||
homeDirectory: /home/staff
|
|
||||||
userPassword: password
|
|
||||||
216
package-lock.json
generated
216
package-lock.json
generated
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "html",
|
"name": "travel",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -439,6 +439,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/popperjs"
|
"url": "https://opencollective.com/popperjs"
|
||||||
@@ -969,6 +970,16 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1294,6 +1305,146 @@
|
|||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss-linux-x64-gnu": {
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
"version": "1.31.1",
|
"version": "1.31.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
|
||||||
@@ -1314,6 +1465,66 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -1373,6 +1584,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -1399,6 +1611,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -1587,6 +1800,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ new #[Layout('components.layouts.guest')] class extends Component {
|
|||||||
{
|
{
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
if (Auth::attempt(['uid' => $this->username, 'password' => $this->password], $this->rememberMe)) {
|
if (Auth::attempt(['username' => $this->username, 'password' => $this->password], $this->rememberMe)) {
|
||||||
session()->regenerate();
|
session()->regenerate();
|
||||||
$this->redirectIntended(route('dashboard'), navigate: true);
|
$this->redirectIntended(route('dashboard'), navigate: true);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ new #[Layout('components.layouts.app')] class extends Component {
|
|||||||
public function saveDraft(): void
|
public function saveDraft(): void
|
||||||
{
|
{
|
||||||
$this->saveRequest(submit: false);
|
$this->saveRequest(submit: false);
|
||||||
|
session()->flash('success', 'Draft saved successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit(): void
|
public function submit(): void
|
||||||
@@ -101,12 +102,6 @@ new #[Layout('components.layouts.app')] class extends Component {
|
|||||||
|
|
||||||
private function saveRequest(bool $submit): void
|
private function saveRequest(bool $submit): void
|
||||||
{
|
{
|
||||||
if ($submit && ! ApprovalWorkflow::where('is_active', true)->exists()) {
|
|
||||||
$this->addError('workflow', 'No active approval workflow is configured. Please contact an administrator.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'emergencyFullName' => ['required', 'string', 'max:255'],
|
'emergencyFullName' => ['required', 'string', 'max:255'],
|
||||||
'emergencyPhone' => ['required', 'string', 'max:50'],
|
'emergencyPhone' => ['required', 'string', 'max:50'],
|
||||||
@@ -187,8 +182,6 @@ new #[Layout('components.layouts.app')] class extends Component {
|
|||||||
if ($submit) {
|
if ($submit) {
|
||||||
app(ApprovalService::class)->submit($travelRequest);
|
app(ApprovalService::class)->submit($travelRequest);
|
||||||
session()->flash('success', 'Travel request submitted for approval.');
|
session()->flash('success', 'Travel request submitted for approval.');
|
||||||
} else {
|
|
||||||
session()->flash('success', 'Draft saved successfully.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->redirect(route('travel-requests.show', $travelRequest), navigate: true);
|
$this->redirect(route('travel-requests.show', $travelRequest), navigate: true);
|
||||||
@@ -216,10 +209,6 @@ new #[Layout('components.layouts.app')] class extends Component {
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@error('workflow')
|
|
||||||
<div class="alert alert-danger">{{ $message }}</div>
|
|
||||||
@enderror
|
|
||||||
|
|
||||||
<form wire:submit.prevent>
|
<form wire:submit.prevent>
|
||||||
|
|
||||||
{{-- Applicant Details --}}
|
{{-- Applicant Details --}}
|
||||||
@@ -320,42 +309,10 @@ new #[Layout('components.layouts.app')] class extends Component {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Journeys --}}
|
{{-- Journeys --}}
|
||||||
<div class="card mb-4" x-data="{
|
<div class="card mb-4">
|
||||||
stateFilter: '',
|
|
||||||
suggestions: {},
|
|
||||||
open: {},
|
|
||||||
async searchTowns(key, query) {
|
|
||||||
if (query.length < 2) {
|
|
||||||
this.suggestions[key] = [];
|
|
||||||
this.open[key] = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const params = new URLSearchParams({ query });
|
|
||||||
if (this.stateFilter) params.append('state', this.stateFilter);
|
|
||||||
const res = await fetch(`/towns/search?${params}`, {
|
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
this.suggestions[key] = data;
|
|
||||||
this.open[key] = data.length > 0;
|
|
||||||
},
|
|
||||||
selectTown(key, wirePath, name) {
|
|
||||||
$wire.set(wirePath, name);
|
|
||||||
this.suggestions[key] = [];
|
|
||||||
this.open[key] = false;
|
|
||||||
}
|
|
||||||
}">
|
|
||||||
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
|
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
|
||||||
<span>Journeys</span>
|
Journeys
|
||||||
<div class="d-flex align-items-center gap-2">
|
<button type="button" wire:click="addJourney" class="btn btn-sm btn-outline-primary">+ Add Journey</button>
|
||||||
<select x-model="stateFilter" class="form-select form-select-sm" style="width: auto;" title="Filter town autocomplete by state">
|
|
||||||
<option value="">All states</option>
|
|
||||||
@foreach(\App\Enums\AustralianState::cases() as $state)
|
|
||||||
<option value="{{ $state->value }}">{{ $state->abbreviation() }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
<button type="button" wire:click="addJourney" class="btn btn-sm btn-outline-primary">+ Add Journey</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@error('journeys') <div class="alert alert-danger">{{ $message }}</div> @enderror
|
@error('journeys') <div class="alert alert-danger">{{ $message }}</div> @enderror
|
||||||
@@ -371,53 +328,13 @@ new #[Layout('components.layouts.app')] class extends Component {
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">Origin <span class="text-danger">*</span></label>
|
<label class="form-label">Origin <span class="text-danger">*</span></label>
|
||||||
<div class="position-relative">
|
<input type="text" wire:model="journeys.{{ $i }}.origin" class="form-control @error('journeys.'.$i.'.origin') is-invalid @enderror" placeholder="e.g. Perth">
|
||||||
<input
|
@error('journeys.'.$i.'.origin') <div class="invalid-feedback">{{ $message }}</div> @enderror
|
||||||
type="text"
|
|
||||||
wire:model="journeys.{{ $i }}.origin"
|
|
||||||
class="form-control @error('journeys.'.$i.'.origin') is-invalid @enderror"
|
|
||||||
placeholder="e.g. Perth"
|
|
||||||
autocomplete="off"
|
|
||||||
x-on:input.debounce.300ms="searchTowns('{{ $i }}-origin', $event.target.value)"
|
|
||||||
x-on:blur="$nextTick(() => open['{{ $i }}-origin'] = false)"
|
|
||||||
>
|
|
||||||
<ul class="dropdown-menu w-100" x-show="open['{{ $i }}-origin'] ?? false" style="z-index: 1055;">
|
|
||||||
<template x-for="s in (suggestions['{{ $i }}-origin'] ?? [])">
|
|
||||||
<li>
|
|
||||||
<button type="button" class="dropdown-item small"
|
|
||||||
x-text="s.label"
|
|
||||||
x-on:mousedown.prevent="selectTown('{{ $i }}-origin', 'journeys.{{ $i }}.origin', s.name)"
|
|
||||||
></button>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
@error('journeys.'.$i.'.origin') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">Destination <span class="text-danger">*</span></label>
|
<label class="form-label">Destination <span class="text-danger">*</span></label>
|
||||||
<div class="position-relative">
|
<input type="text" wire:model="journeys.{{ $i }}.destination" class="form-control @error('journeys.'.$i.'.destination') is-invalid @enderror" placeholder="e.g. Sydney">
|
||||||
<input
|
@error('journeys.'.$i.'.destination') <div class="invalid-feedback">{{ $message }}</div> @enderror
|
||||||
type="text"
|
|
||||||
wire:model="journeys.{{ $i }}.destination"
|
|
||||||
class="form-control @error('journeys.'.$i.'.destination') is-invalid @enderror"
|
|
||||||
placeholder="e.g. Sydney"
|
|
||||||
autocomplete="off"
|
|
||||||
x-on:input.debounce.300ms="searchTowns('{{ $i }}-destination', $event.target.value)"
|
|
||||||
x-on:blur="$nextTick(() => open['{{ $i }}-destination'] = false)"
|
|
||||||
>
|
|
||||||
<ul class="dropdown-menu w-100" x-show="open['{{ $i }}-destination'] ?? false" style="z-index: 1055;">
|
|
||||||
<template x-for="s in (suggestions['{{ $i }}-destination'] ?? [])">
|
|
||||||
<li>
|
|
||||||
<button type="button" class="dropdown-item small"
|
|
||||||
x-text="s.label"
|
|
||||||
x-on:mousedown.prevent="selectTown('{{ $i }}-destination', 'journeys.{{ $i }}.destination', s.name)"
|
|
||||||
></button>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
@error('journeys.'.$i.'.destination') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Date <span class="text-danger">*</span></label>
|
<label class="form-label">Date <span class="text-danger">*</span></label>
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Jobs\SyncTowns;
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
Schedule::job(new SyncTowns)->dailyAt('02:00');
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Town;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -13,34 +11,9 @@ Route::post('/logout', function () {
|
|||||||
Auth::logout();
|
Auth::logout();
|
||||||
session()->invalidate();
|
session()->invalidate();
|
||||||
session()->regenerateToken();
|
session()->regenerateToken();
|
||||||
|
|
||||||
return redirect()->route('login');
|
return redirect()->route('login');
|
||||||
})->name('logout')->middleware('auth');
|
})->name('logout')->middleware('auth');
|
||||||
|
|
||||||
// Town autocomplete search
|
|
||||||
Route::middleware(['auth'])->get('/towns/search', function (Request $request) {
|
|
||||||
$query = $request->string('query')->trim();
|
|
||||||
|
|
||||||
if ($query->length() < 2) {
|
|
||||||
return response()->json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$towns = Town::active()
|
|
||||||
->search($query)
|
|
||||||
->when($request->filled('state'), fn ($q) => $q->where('state', $request->integer('state')))
|
|
||||||
->orderBy('town_name')
|
|
||||||
->limit(10)
|
|
||||||
->get(['town_name', 'state']);
|
|
||||||
|
|
||||||
return response()->json(
|
|
||||||
$towns->map(fn ($town) => [
|
|
||||||
'name' => $town->town_name,
|
|
||||||
'state' => $town->state->abbreviation(),
|
|
||||||
'label' => $town->town_name.', '.$town->state->abbreviation(),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
})->name('towns.search');
|
|
||||||
|
|
||||||
// Authenticated
|
// Authenticated
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
Route::livewire('/dashboard', 'dashboard')->name('dashboard');
|
Route::livewire('/dashboard', 'dashboard')->name('dashboard');
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Jobs;
|
|
||||||
|
|
||||||
use App\Jobs\SyncTowns;
|
|
||||||
use App\Models\Town;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class SyncTownsTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
private const API_URL = 'https://services-ap1.arcgis.com/ypkPEy1AmwPKGNNv/arcgis/rest/services/Town_Point/FeatureServer/0/query*';
|
|
||||||
|
|
||||||
private function makeTownFeature(array $overrides = []): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'attributes' => array_merge([
|
|
||||||
'town_pid' => 'TWN0001',
|
|
||||||
'town_name' => 'Perth',
|
|
||||||
'state' => 5,
|
|
||||||
'population' => 2000000,
|
|
||||||
'town_class' => 1,
|
|
||||||
'date_retired' => null,
|
|
||||||
], $overrides),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_syncs_towns_from_api(): void
|
|
||||||
{
|
|
||||||
Http::fake([
|
|
||||||
self::API_URL => Http::sequence()
|
|
||||||
->push(['features' => [
|
|
||||||
$this->makeTownFeature(['town_pid' => 'TWN0001', 'town_name' => 'Perth', 'state' => 5]),
|
|
||||||
$this->makeTownFeature(['town_pid' => 'TWN0002', 'town_name' => 'Fremantle', 'state' => 5]),
|
|
||||||
]])
|
|
||||||
->push(['features' => []]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
(new SyncTowns)->handle();
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('towns', ['town_pid' => 'TWN0001', 'town_name' => 'Perth']);
|
|
||||||
$this->assertDatabaseHas('towns', ['town_pid' => 'TWN0002', 'town_name' => 'Fremantle']);
|
|
||||||
$this->assertDatabaseCount('towns', 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_paginates_through_all_pages(): void
|
|
||||||
{
|
|
||||||
$pageOne = [];
|
|
||||||
for ($i = 1; $i <= 1000; $i++) {
|
|
||||||
$pageOne[] = $this->makeTownFeature(['town_pid' => 'TWN'.str_pad($i, 4, '0', STR_PAD_LEFT)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
self::API_URL => Http::sequence()
|
|
||||||
->push(['features' => $pageOne])
|
|
||||||
->push(['features' => [
|
|
||||||
$this->makeTownFeature(['town_pid' => 'TWN1001', 'town_name' => 'Albany']),
|
|
||||||
]])
|
|
||||||
->push(['features' => []]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
(new SyncTowns)->handle();
|
|
||||||
|
|
||||||
$this->assertDatabaseCount('towns', 1001);
|
|
||||||
$this->assertDatabaseHas('towns', ['town_pid' => 'TWN1001', 'town_name' => 'Albany']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_upserts_existing_towns_with_updated_data(): void
|
|
||||||
{
|
|
||||||
Town::factory()->create([
|
|
||||||
'town_pid' => 'TWN0001',
|
|
||||||
'town_name' => 'Old Name',
|
|
||||||
'state' => 5,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
self::API_URL => Http::sequence()
|
|
||||||
->push(['features' => [
|
|
||||||
$this->makeTownFeature(['town_pid' => 'TWN0001', 'town_name' => 'Perth', 'population' => 2100000]),
|
|
||||||
]])
|
|
||||||
->push(['features' => []]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
(new SyncTowns)->handle();
|
|
||||||
|
|
||||||
$this->assertDatabaseCount('towns', 1);
|
|
||||||
$this->assertDatabaseHas('towns', ['town_pid' => 'TWN0001', 'town_name' => 'Perth', 'population' => 2100000]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_marks_retired_towns_with_date_retired(): void
|
|
||||||
{
|
|
||||||
$retiredAt = Carbon::parse('2024-01-15')->startOfDay();
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
self::API_URL => Http::sequence()
|
|
||||||
->push(['features' => [
|
|
||||||
$this->makeTownFeature([
|
|
||||||
'town_pid' => 'TWN0001',
|
|
||||||
'town_name' => 'OldTown',
|
|
||||||
'date_retired' => $retiredAt->getTimestampMs(),
|
|
||||||
]),
|
|
||||||
]])
|
|
||||||
->push(['features' => []]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
(new SyncTowns)->handle();
|
|
||||||
|
|
||||||
$town = Town::where('town_pid', 'TWN0001')->first();
|
|
||||||
$this->assertNotNull($town->date_retired);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_logs_error_and_stops_when_api_fails(): void
|
|
||||||
{
|
|
||||||
Log::spy();
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
self::API_URL => Http::response(null, 500),
|
|
||||||
]);
|
|
||||||
|
|
||||||
(new SyncTowns)->handle();
|
|
||||||
|
|
||||||
$this->assertDatabaseCount('towns', 0);
|
|
||||||
Log::shouldHaveReceived('error')
|
|
||||||
->once()
|
|
||||||
->with('SyncTowns: API request failed', \Mockery::any());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_logs_synced_count_on_success(): void
|
|
||||||
{
|
|
||||||
Log::spy();
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
self::API_URL => Http::sequence()
|
|
||||||
->push(['features' => [
|
|
||||||
$this->makeTownFeature(['town_pid' => 'TWN0001']),
|
|
||||||
$this->makeTownFeature(['town_pid' => 'TWN0002']),
|
|
||||||
]])
|
|
||||||
->push(['features' => []]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
(new SyncTowns)->handle();
|
|
||||||
|
|
||||||
Log::shouldHaveReceived('info')->once()->with('SyncTowns: synced 2 towns.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Towns;
|
|
||||||
|
|
||||||
use App\Enums\AustralianState;
|
|
||||||
use App\Models\Town;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class TownSearchTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
private function actingAsUser(): static
|
|
||||||
{
|
|
||||||
return $this->actingAs(User::factory()->create());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_requires_authentication(): void
|
|
||||||
{
|
|
||||||
$this->get('/towns/search?query=Perth')->assertRedirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_returns_empty_array_for_query_shorter_than_two_characters(): void
|
|
||||||
{
|
|
||||||
Town::factory()->create(['town_name' => 'Perth']);
|
|
||||||
|
|
||||||
$this->actingAsUser()
|
|
||||||
->getJson('/towns/search?query=P')
|
|
||||||
->assertOk()
|
|
||||||
->assertExactJson([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_returns_matching_towns_by_prefix(): void
|
|
||||||
{
|
|
||||||
Town::factory()->create(['town_name' => 'Perth', 'state' => AustralianState::WA->value]);
|
|
||||||
Town::factory()->create(['town_name' => 'Pertham', 'state' => AustralianState::WA->value]);
|
|
||||||
Town::factory()->create(['town_name' => 'Sydney', 'state' => AustralianState::NSW->value]);
|
|
||||||
|
|
||||||
$response = $this->actingAsUser()
|
|
||||||
->getJson('/towns/search?query=Per')
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->assertCount(2, $response->json());
|
|
||||||
$this->assertSame('Perth, WA', $response->json('0.label'));
|
|
||||||
$this->assertSame('Pertham, WA', $response->json('1.label'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_filters_results_by_state(): void
|
|
||||||
{
|
|
||||||
Town::factory()->create(['town_name' => 'Port Hedland', 'state' => AustralianState::WA->value]);
|
|
||||||
Town::factory()->create(['town_name' => 'Port Augusta', 'state' => AustralianState::SA->value]);
|
|
||||||
|
|
||||||
$response = $this->actingAsUser()
|
|
||||||
->getJson('/towns/search?query=Port&state='.AustralianState::WA->value)
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->assertCount(1, $response->json());
|
|
||||||
$this->assertSame('Port Hedland', $response->json('0.name'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_excludes_retired_towns(): void
|
|
||||||
{
|
|
||||||
Town::factory()->create(['town_name' => 'Perth', 'state' => AustralianState::WA->value]);
|
|
||||||
Town::factory()->retired()->create(['town_name' => 'Perthville', 'state' => AustralianState::WA->value]);
|
|
||||||
|
|
||||||
$response = $this->actingAsUser()
|
|
||||||
->getJson('/towns/search?query=Per')
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->assertCount(1, $response->json());
|
|
||||||
$this->assertSame('Perth', $response->json('0.name'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_returns_at_most_ten_results(): void
|
|
||||||
{
|
|
||||||
Town::factory()->count(15)->create([
|
|
||||||
'town_name' => 'Perth',
|
|
||||||
'state' => AustralianState::WA->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAsUser()
|
|
||||||
->getJson('/towns/search?query=Per')
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->assertCount(10, $response->json());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_response_includes_name_state_and_label_fields(): void
|
|
||||||
{
|
|
||||||
Town::factory()->create(['town_name' => 'Perth', 'state' => AustralianState::WA->value]);
|
|
||||||
|
|
||||||
$response = $this->actingAsUser()
|
|
||||||
->getJson('/towns/search?query=Per')
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$result = $response->json('0');
|
|
||||||
$this->assertArrayHasKey('name', $result);
|
|
||||||
$this->assertArrayHasKey('state', $result);
|
|
||||||
$this->assertArrayHasKey('label', $result);
|
|
||||||
$this->assertSame('Perth', $result['name']);
|
|
||||||
$this->assertSame('WA', $result['state']);
|
|
||||||
$this->assertSame('Perth, WA', $result['label']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_results_are_ordered_alphabetically(): void
|
|
||||||
{
|
|
||||||
Town::factory()->create(['town_name' => 'Pertham', 'state' => AustralianState::WA->value]);
|
|
||||||
Town::factory()->create(['town_name' => 'Perth', 'state' => AustralianState::WA->value]);
|
|
||||||
|
|
||||||
$response = $this->actingAsUser()
|
|
||||||
->getJson('/towns/search?query=Per')
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->assertSame('Perth', $response->json('0.name'));
|
|
||||||
$this->assertSame('Pertham', $response->json('1.name'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use App\Enums\ApprovalStatus;
|
|
||||||
use App\Enums\JourneyMethod;
|
|
||||||
use App\Enums\TravelStatus;
|
|
||||||
use App\Models\ApprovalStep;
|
|
||||||
use App\Models\ApprovalWorkflow;
|
|
||||||
use App\Models\TravelRequest;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use Spatie\Permission\Models\Role;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class TravelRequestSubmissionTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
private User $staff;
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
private array $validFormData;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
|
|
||||||
Role::firstOrCreate(['name' => 'staff']);
|
|
||||||
Role::firstOrCreate(['name' => 'travel_approver']);
|
|
||||||
Role::firstOrCreate(['name' => 'administrator']);
|
|
||||||
|
|
||||||
$this->staff = User::factory()->create();
|
|
||||||
$this->staff->assignRole('staff');
|
|
||||||
|
|
||||||
$this->validFormData = [
|
|
||||||
'emergencyFullName' => 'Jane Doe',
|
|
||||||
'emergencyPhone' => '0400000000',
|
|
||||||
'emergencyRelationship' => 'Spouse',
|
|
||||||
'reasonSummary' => 'Attending a medical conference in Sydney',
|
|
||||||
'journeys' => [
|
|
||||||
[
|
|
||||||
'origin' => 'Perth',
|
|
||||||
'destination' => 'Sydney',
|
|
||||||
'date' => '2026-04-01',
|
|
||||||
'time' => '09:00',
|
|
||||||
'method' => JourneyMethod::Air->value,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'costCodes' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeWorkflow(): ApprovalWorkflow
|
|
||||||
{
|
|
||||||
$workflow = ApprovalWorkflow::factory()->create(['is_active' => true]);
|
|
||||||
|
|
||||||
ApprovalStep::factory()->create([
|
|
||||||
'workflow_id' => $workflow->id,
|
|
||||||
'order' => 1,
|
|
||||||
'name' => 'Travel Approver Review',
|
|
||||||
'role' => 'travel_approver',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $workflow;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_submitting_form_with_active_workflow_sets_status_to_pending(): void
|
|
||||||
{
|
|
||||||
$this->makeWorkflow();
|
|
||||||
|
|
||||||
Livewire::actingAs($this->staff)
|
|
||||||
->test('travel-request.create')
|
|
||||||
->set($this->validFormData)
|
|
||||||
->call('submit');
|
|
||||||
|
|
||||||
$request = TravelRequest::first();
|
|
||||||
$this->assertNotNull($request);
|
|
||||||
$this->assertSame(TravelStatus::Pending, $request->status);
|
|
||||||
$this->assertNotNull($request->submitted_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_submitting_form_with_active_workflow_creates_pending_approval(): void
|
|
||||||
{
|
|
||||||
$this->makeWorkflow();
|
|
||||||
|
|
||||||
Livewire::actingAs($this->staff)
|
|
||||||
->test('travel-request.create')
|
|
||||||
->set($this->validFormData)
|
|
||||||
->call('submit');
|
|
||||||
|
|
||||||
$request = TravelRequest::first();
|
|
||||||
$this->assertCount(1, $request->approvals);
|
|
||||||
$this->assertSame(ApprovalStatus::Pending->value, $request->approvals->first()->status->value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_submitting_form_with_no_active_workflow_shows_error(): void
|
|
||||||
{
|
|
||||||
Livewire::actingAs($this->staff)
|
|
||||||
->test('travel-request.create')
|
|
||||||
->set($this->validFormData)
|
|
||||||
->call('submit')
|
|
||||||
->assertHasErrors(['workflow']);
|
|
||||||
|
|
||||||
$this->assertDatabaseCount('travel_requests', 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_saving_draft_does_not_submit_for_approval(): void
|
|
||||||
{
|
|
||||||
$this->makeWorkflow();
|
|
||||||
|
|
||||||
Livewire::actingAs($this->staff)
|
|
||||||
->test('travel-request.create')
|
|
||||||
->set($this->validFormData)
|
|
||||||
->call('saveDraft');
|
|
||||||
|
|
||||||
$request = TravelRequest::first();
|
|
||||||
$this->assertNotNull($request);
|
|
||||||
$this->assertSame(TravelStatus::Draft, $request->status);
|
|
||||||
$this->assertNull($request->submitted_at);
|
|
||||||
$this->assertCount(0, $request->approvals);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user