Compare commits

...

21 Commits

Author SHA1 Message Date
hackerESQ 23ed2e7155 wip 2026-03-24 21:04:40 -05:00
hackerESQ e8997ecc3e Create BinanceMarketData.php 2026-03-24 19:01:09 -05:00
hackerESQ d449a89349 Show only market gain on performance chart (#190)
(by default) Allow other options as a choice
2026-03-20 20:51:19 -05:00
hackerESQ a98dcd0732 fix escaping issue 2026-03-18 20:30:09 -05:00
hackerESQ eaaa218582 Clean up formatting of locale form and dropdowns (#186) 2026-03-18 20:23:51 -05:00
hackerESQ 67396b23f1 Add french translations (#185) 2026-03-18 20:23:31 -05:00
hackerESQ 01825e9108 Fix transaction table scope to my portfolios only (#184)
* Limit transactions table filters to `my portfolios` scope

* Fix scope of transactions table
2026-03-18 19:54:18 -05:00
hackerESQ d55f117565 Limit transactions table filters to my portfolios scope (#183) 2026-03-18 17:43:28 -05:00
hackerESQ 327e120a3c clean up 2026-03-18 17:40:31 -05:00
hackerESQ 5e8324551b Remove custom date picker element (#182) 2026-03-16 20:18:08 -05:00
hackerESQ 935a5020db Merge branch 'main' of https://github.com/investbrainapp/investbrain 2026-03-15 18:24:42 -05:00
hackerESQ 0b9f7d5254 add intl to build set to satisfy filament 2026-03-15 18:24:40 -05:00
hackerESQ 6ea4b5c12a Remove swap space increase step from workflow
Removed the step to increase swap space in the build job.
2026-03-15 18:10:35 -05:00
hackerESQ d3337c7c01 Change runner to self-hosted for build job 2026-03-15 17:53:43 -05:00
hackerESQ 23a9b84659 fix: upgrade @alpinejs/persist from 3.15.0 to 3.15.4 (#178)
Snyk has created this PR to upgrade @alpinejs/persist from 3.15.0 to 3.15.4.

See this package in npm:
@alpinejs/persist

See this project in Snyk:
https://app.snyk.io/org/investbrain/project/6a72866f-936e-45fc-83bd-dd298e632086?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2026-03-15 17:04:40 -05:00
Carlos E. Barboza 6bc174a87b Update Dividend.php (#176)
* Adds a second join condition requiring holdings.symbol = dividends.symbol. 
Without this, the join only matches on portfolio_id, which could incorrectly associate dividends with holdings of a different symbol within the same portfolio.

* Add test

---------

Co-authored-by: hackerESQ <corey@coreyvarma.com>
2026-03-15 17:00:29 -05:00
hackerESQ 401b0eef91 Merge branch 'main' of https://github.com/investbrainapp/investbrain 2026-03-13 20:16:18 -05:00
hackerESQ 98f47baa73 wip 2026-03-13 20:16:14 -05:00
hackerESQ 66889abc72 Migrate to laravel ai sdk (#181)
Also
* upgrade to livewire 4
* replace rappsoft tables with filament
2026-03-13 15:21:22 -05:00
hackerESQ fc6b7a8c52 dev: add laravel boost 2026-02-25 22:31:20 -06:00
hackerESQ 34223960f8 chore: Bump PHP version to 8.4
see #150
2025-11-06 21:10:53 -06:00
73 changed files with 5219 additions and 1800 deletions
+1 -2
View File
@@ -16,9 +16,8 @@ REGISTRATION_ENABLED=true
# Enable or disable AI chat feature
AI_CHAT_ENABLED=false
# API key for OpenAI (for Llama support, see docs)
# API key for OpenAI (for other labs or Ollama support, see docs)
OPENAI_API_KEY=
OPENAI_ORGANIZATION=
# Market data provider to use (comma separated list)
MARKET_DATA_PROVIDER=yahoo
+1 -4
View File
@@ -8,11 +8,8 @@ on:
jobs:
build:
runs-on: ubuntu-22.04 #ubuntu-latest
runs-on: self-hosted
steps:
- name: Increase swap space
run: sudo /bin/dd if=/dev/zero of=/var/swap.1 bs=1M count=5120 && sudo chmod 600 /var/swap.1 && sudo /sbin/mkswap /var/swap.1 && sudo /sbin/swapon /var/swap.1
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}
+452
View File
@@ -0,0 +1,452 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
## Foundational Context
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.18
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v4
- livewire/volt (VOLT) - v1
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- phpunit/phpunit (PHPUNIT) - v11
- alpinejs (ALPINEJS) - v3
- tailwindcss (TAILWINDCSS) - v4
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
## PHP
- Always use curly braces for control structures, even if it has one line.
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== herd rules ===
## Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== 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 ===
## Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
## Laravel 12
- Use the `search-docs` tool to get version-specific documentation.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules ===
## Livewire
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle Hook Examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== volt/core rules ===
## Livewire Volt
- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt.
- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`.
- Volt is a class-based and functional API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to coexist in the same file.
- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@volt` directive.
- You must check existing Volt components to determine if they're functional or class-based. If you can't detect that, ask the user which they prefer before writing a Volt component.
### Volt Functional Component Example
<code-snippet name="Volt Functional Component Example" lang="php">
@volt
<?php
use function Livewire\Volt\{state, computed};
state(['count' => 0]);
$increment = fn () => $this->count++;
$decrement = fn () => $this->count--;
$double = computed(fn () => $this->count * 2);
?>
<div>
<h1>Count: {{ $count }}</h1>
<h2>Double: {{ $this->double }}</h2>
<button wire:click="increment">+</button>
<button wire:click="decrement">-</button>
</div>
@endvolt
</code-snippet>
### Volt Class Based Component Example
To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
<code-snippet name="Volt Class-based Volt Component Example" lang="php">
use Livewire\Volt\Component;
new class extends Component {
public $count = 0;
public function increment()
{
$this->count++;
}
} ?>
<div>
<h1>{{ $count }}</h1>
<button wire:click="increment">+</button>
</div>
</code-snippet>
### Testing Volt & Volt Components
- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
<code-snippet name="Livewire Test Example" lang="php">
use Livewire\Volt\Volt;
test('counter increments', function () {
Volt::test('counter')
->assertSee('Count: 0')
->call('increment')
->assertSee('Count: 1');
});
</code-snippet>
<code-snippet name="Volt Component Test Using Pest" lang="php">
declare(strict_types=1);
use App\Models\{User, Product};
use Livewire\Volt\Volt;
test('product form creates product', function () {
$user = User::factory()->create();
Volt::test('pages.products.create')
->actingAs($user)
->set('form.name', 'Test Product')
->set('form.description', 'Test Description')
->set('form.price', 99.99)
->call('create')
->assertHasNoErrors();
expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
});
</code-snippet>
### Common Patterns
<code-snippet name="CRUD With Volt" lang="php">
<?php
use App\Models\Product;
use function Livewire\Volt\{state, computed};
state(['editing' => null, 'search' => '']);
$products = computed(fn() => Product::when($this->search,
fn($q) => $q->where('name', 'like', "%{$this->search}%")
)->get());
$edit = fn(Product $product) => $this->editing = $product->id;
$delete = fn(Product $product) => $product->delete();
?>
<!-- HTML / UI Here -->
</code-snippet>
<code-snippet name="Real-Time Search With Volt" lang="php">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="Search..."
/>
</code-snippet>
<code-snippet name="Loading States With Volt" lang="php">
<flux:button wire:click="save" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving...</span>
</flux:button>
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
## PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should test all of the happy paths, failure paths, and weird paths.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
### Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- 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).
=== tailwindcss/core rules ===
## Tailwind CSS
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing; don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind CSS 4
- Always use Tailwind CSS v4; do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
<code-snippet name="Extending Theme in CSS" lang="css">
@theme {
--color-brand: oklch(0.72 0.11 178);
}
</code-snippet>
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
</laravel-boost-guidelines>
+11 -7
View File
@@ -64,9 +64,11 @@ Congrats! You've just installed Investbrain!
Investbrain offers an AI powered chat assistant that is grounded on *your* investments. This enables you to use AI as a thought partner when making investment decisions.
When self-hosting, you can enable the chat assistant by configuring your OpenAI Secret Key and Organization ID in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file. Navigate to OpenAI to [create your keys](https://platform.openai.com/api-keys).
Most of the major labs are currently supported (OpenAI, Anthropic, Gemini, xAI, etc). You'll need to obtain API keys from your selected provider and configure that in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file using the appropriate keys.
If you are self-hosting your own large language models ("LLMs") that expose an OpenAI compatible API (e.g. [Ollama](https://ollama.com/blog/openai-compatibility)), you can update the `OPENAI_BASE_URI` configuration to your self-hosted instance. Ensure you also update the `OPENAI_MODEL` to an available model.
Investbrain is also compatible with Ollama (and other OpenAI compatible APIs). If you are self-hosting your own large language models ("LLMs") that exposes an OpenAI compatible API (e.g. [Ollama](https://ollama.com/blog/openai-compatibility)), you'll need to configure your local endpoint in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file.
See available options [below](#configuration).
Always keep in mind the limitations of LLMs. When in doubt, consult a licensed investment advisor.
@@ -74,7 +76,7 @@ Always keep in mind the limitations of LLMs. When in doubt, consult a licensed i
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as [Yahoo Finance](https://finance.yahoo.com/), [Twelve Data](https://twelvedata.com), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), [Alpaca](https://alpaca.markets/), and [Alpha Vantage](https://www.alphavantage.co/support/). The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
### Configuration
### Market Data Configuration
You can specify the market data provider you want to use in your environment variables:
@@ -145,10 +147,12 @@ There are several optional configurations available when installing using the re
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
| OPENAI_API_KEY | OpenAI secret key (required for AI chat) | `null` |
| OPENAI_ORGANIZATION | OpenAI org id (required for AI chat) | `null` |
| OPENAI_MODEL | The selected LLM used for AI chat | gpt-4o |
| OPENAI_BASE_URI | The URI for your self-hosted LLM | api.openai.com/v1 |
| CHAT_PROVIDER | Which chat provider to use (one of `openai`, `anthropic`, `gemini`, `azure`, `groq`, `xai`, `deepseek`, `mistral`, `ollama`) | `openai` |
| CHAT_MODEL | The selected LLM used for AI chat | defaults to current smartest model from lab |
| ANTHROPIC_API_KEY | If using Anthropic for chat | `null` |
| OPENAI_API_KEY | If using OpenAI for chat | `null` |
| OLLAMA_BASE_URL | If using Ollama for chat | `http://localhost:11434` |
| OLLAMA_API_KEY | May be required if using Ollama for chat | `null` |
| DAILY_CHANGE_TIME | The time of day to capture daily change | 23:00 |
| REGISTRATION_ENABLED | Whether to enable registration of new users | `true` |
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Ai\Agents;
use App\Models\Holding;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Promptable;
use Stringable;
class ChatWithHoldingAgent implements Agent, Conversational, HasTools
{
use Promptable;
use RemembersConversations;
public function __construct(public readonly Holding $holding) {}
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
$holding = $this->holding;
$quantity = $holding->quantity > 0
? 'a total of '.$holding->quantity
: 'ZERO';
return 'Most recent training data: '.now()->toDateString().'.
You are an investment portfolio assistant providing advice to an investor. Use the following information to provide relevant recommendations. Use the words \'likely\' or \'may\' instead of concrete statements (except for obvious statements of fact or common sense). Do not apologize. Be polite, but minimize gratuitous niceties. If something is unclear, ask for clarification. When referencing numbers with precision, always round to the nearest 100th decimal place. If no precision, display numbers in integers.
The investor owns '.$quantity.' shares of '.$holding->market_data->name.' (ticker: '.$holding->symbol.') with an average cost basis of '.$holding->average_cost_basis.'. Here are the relevant transactions - sales and purchases of '.$holding->symbol.':
'.$holding->getFormattedTransactions().'
This investor has earned $ '.$holding->dividends_earned.' in dividends so far and earned '.$holding->realized_gains_dollars.' in realized gains (sales) from '.$holding->symbol.' in this portfolio.
The current market price for '.$holding->symbol.' is '.$holding->market_data->market_value.'. Additionally, here\'s other critical fundamentals for '.$holding->market_data->name.' that might help:
* Market cap: '.$holding->market_data->market_cap.'
* Forward PE: '.$holding->market_data->forward_pe.'
* Trailing PE: '.$holding->market_data->trailing_pe.'
* Book value: '.$holding->market_data->book_value.'
* 52 week low: '.$holding->market_data->fifty_two_week_low.'
* 52 week high: '.$holding->market_data->fifty_two_week_high.'
* Dividend yield: '.$holding->market_data->dividend_yield.'
Based on this current market data, quantity owned, and average cost basis, you should determine if the '.$holding->symbol.' holding is making or losing money.
Below is the question from the investor. Considering these facts, provide a concise response to the following question (give a direct response). Limit your response to no more than 75 words and consider using a common decision framework. Use github style markdown for any formatting:';
}
/**
* Get the tools available to the agent.
*
* @return Tool[]
*/
public function tools(): iterable
{
return [];
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Ai\Agents;
use App\Models\Portfolio;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Promptable;
use Stringable;
class ChatWithPortfolioAgent implements Agent, Conversational, HasTools
{
use Promptable;
use RemembersConversations;
public function __construct(public readonly Portfolio $portfolio) {}
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
return 'Most recent training data: '.now()->toDateString().'.
You are an investment portfolio assistant providing advice to an investor. Use the following information to provide relevant recommendations. Use the words \'likely\' or \'may\' in lieu of concrete statements (except for obvious statements of fact or common sense). Do not apologize. Be polite, but minimize gratuitous niceties. When referencing numbers with precision, always round to the nearest 100th decimal place. If no precision, display numbers in integers.
The investor has the following holdings in this portfolio:
'.$this->portfolio->getFormattedHoldings().'
Based on the current market data, quantity owned, and average cost basis, you can determine the performance of any holding.
Below is the question from the investor. Considering these facts, provide a concise response to the following question (give a direct response). Limit your response to no more than 75 words and consider using a common decision framework. Use github style markdown for any formatting:';
}
/**
* Get the tools available to the agent.
*
* @return Tool[]
*/
public function tools(): iterable
{
return [];
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Messages;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Promptable;
use Stringable;
class ChatWithSuggestedPromptsAgent implements Agent, HasStructuredOutput, HasTools
{
use Promptable;
public function __construct(
public array $messages
) {}
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
return 'Your role is a savvy helper that assists curious investors in asking thoughtful questions to investment advisors.
You should recommend between 1 and 5 (no more than 5) questions. You should ensure the questions you recommend are based on the provided context. Be sure to keep the questions short!
The questions you recommend might be based on natural follow up from the given context, requests to further refine a previous response, clarify undefined terms, common decision frameworks, possible risks or benefits, or commonly understood investing concepts that may require additional explanation.
Generate between 1 and 5 (no more than 5) follow up questions a curious investor might ask their advisor based on the provided conversation.';
}
/**
* Get the tools available to the agent.
*
* @return Tool[]
*/
public function tools(): iterable
{
return [];
}
/**
* Get the messages available to the agent.
*
* @return Messages[]
*/
public function messages(): iterable
{
return $this->messages;
}
/**
* Get the agent's structured output schema definition.
*/
public function schema(JsonSchema $schema): array
{
return [
'suggested_prompts' => $schema->array()->items(
$schema->object([
'text' => $schema->string()
->description('Short description of suggested prompt (no more than 5 words)')
->required(),
'value' => $schema->string()
->description('The detailed version of the prompt (think good prompt engineering!)')
->required(),
])->withoutAdditionalProperties()
)->required(),
];
}
}
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Interfaces\MarketData;
use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote;
use Carbon\CarbonInterval;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class BinanceMarketData implements MarketDataInterface
{
public PendingRequest $client;
public string $apiBaseUrl = 'https://data-api.binance.vision/api/v3/';
public function __construct()
{
$this->createNewClient();
}
private function createNewClient()
{
$this->client = Http::withOptions([
'headers' => [
'content-type' => 'application/json',
'accept' => 'application/json',
],
]);
}
public function exists(string $symbol): bool
{
return (bool) $this->quote($symbol);
}
public function quote(string $symbol): Quote
{
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters([
'symbol' => $symbol,
])->get('ticker');
$quote = $response->json();
throw_if(empty(Arr::get($quote, 'weightedAvgPrice')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
$fundamental = cache()->remember(
'binance-fdmtl-'.$symbol,
1440,
function () use ($symbol) {
$this->createNewClient();
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters(['symbol' => $symbol])
->get('exchangeInfo');
return $response->json();
}
);
return new Quote([
'name' => $symbol,
'symbol' => $symbol,
'currency' => Arr::get($fundamental, 'symbols.0.quoteAsset'),
'market_value' => (float) Arr::get($quote, 'weightedAvgPrice'),
]);
}
public function dividends(string $symbol, $startDate, $endDate): Collection
{
// noop
return collect();
}
public function splits(string $symbol, $startDate, $endDate): Collection
{
// noop
return collect();
}
public function history(string $symbol, $startDate, $endDate): Collection
{
$startDate = Carbon::parse($startDate);
$endDate = Carbon::parse($endDate);
$allHistory = collect();
$chunks = 500;
$period = CarbonInterval::days($chunks)->toPeriod($startDate, $endDate);
foreach ($period as $startDate) {
$chunkEnd = $startDate->copy()->addDays($chunks - 1);
if ($chunkEnd->gt($endDate)) {
$chunkEnd = $endDate;
}
$this->createNewClient();
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters([
'symbol' => $symbol,
'interval' => '1d',
'startTime' => $startDate->timestamp * 1000,
'endTime' => $chunkEnd->timestamp * 1000,
])->get('klines');
$history = $response->json();
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
$chunkedHistory = collect($history)
->mapWithKeys(function ($history_item) use ($symbol) {
$date = Carbon::parse($history_item[0])->format('Y-m-d');
return [$date => new Ohlc([
'symbol' => $symbol,
'date' => $date,
'close' => $history_item[4],
])];
});
$allHistory = $allHistory->merge($chunkedHistory);
}
return $allHistory;
}
}
-171
View File
@@ -1,171 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Datatables;
use App\Models\Holding;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Number;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
class HoldingsTable extends DataTableComponent
{
public $portfolio;
public array $hiddenColumns = [];
public function mount($portfolio): void
{
//
}
public function builder(): Builder
{
return Holding::query()
->portfolio($this->portfolio->id)
->with(['market_data'])
->withCount(['transactions as num_transactions' => function ($query) {
return $query->whereRaw('transactions.symbol = holdings.symbol');
}])
->withPerformance();
}
public function configure(): void
{
$this->hiddenColumns = ['name', 'average_cost_basis', 'market_value', 'fifty_two_week_low', 'fifty_two_week_high'];
$this->setTableWrapperAttributes([
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'overflow-scroll',
]);
$this->setTableAttributes([
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'table',
]);
$this->setTheadAttributes([
'default' => false,
'default-styling' => true,
'default-colors' => false,
]);
$this->setThAttributes(function (Column $column) {
$attributes = [
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'text-xs font-medium whitespace-nowrap uppercase tracking-wider text-nowrap',
];
if (in_array($column->getField(), $this->hiddenColumns)) {
$attributes['class'] = $attributes['class'].' hidden md:table-cell';
}
return $attributes;
});
$this->setThSortButtonAttributes(fn () => [
'default' => false,
'default-styling' => true,
'default-colors' => false,
'class' => 'cursor-pointer',
]);
$this->setTbodyAttributes([
'default' => false,
'default-styling' => true,
'default-colors' => false,
]);
$this->setTrAttributes(fn () => [
'default' => false,
'default-styling' => true,
'default-colors' => false,
'class' => 'cursor-pointer hover:bg-neutral/25',
]);
$this->setTdAttributes(function (Column $column) {
$attributes = [
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'text-nowrap',
];
if (in_array($column->getField(), $this->hiddenColumns)) {
$attributes['class'] = $attributes['class'].' hidden md:table-cell';
}
return $attributes;
});
$this->setDefaultSort('symbol', 'asc');
$this->setToolsDisabled();
$this->setFooterDisabled();
$this->setPaginationDisabled();
$this->setDisplayPaginationDetailsDisabled();
$this->setPrimaryKey('id');
$this->setTableRowUrl(function ($row) {
return route('holding.show', ['portfolio' => $row->portfolio_id, 'symbol' => $row->symbol]);
})->setTableRowUrlTarget(function ($row) {
return 'navigate';
});
}
public function columns(): array
{
return [
Column::make(__('Symbol'), 'symbol')
->sortable(),
Column::make(__('Name'), 'market_data.name')
->sortable(),
Column::make(__('Quantity'), 'quantity')
->sortable(),
Column::make(__('Average Cost Basis'), 'average_cost_basis')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('Total Cost Basis'), 'total_cost_basis')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('Market Value'), 'market_data.market_value')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('Total Market Value'))
->sortable(fn (Builder $query, string $direction) => $query->orderBy('total_market_value', $direction))
->label(fn ($row) => Number::currency($row->total_market_value ?? 0, $row->market_data?->currency)),
Column::make(__('Market Gain/Loss'))
->html()
->label(fn ($row) => Number::currency($row->market_gain_dollars ?? 0, $row->market_data?->currency).view('components.ui.gain-loss-arrow-badge', [
'costBasis' => $row->average_cost_basis,
'marketValue' => $row->market_data?->market_value,
'small' => true,
]))
->sortable(fn (Builder $query, string $direction) => $query->orderBy('market_gain_dollars', $direction)),
Column::make(__('Realized Gain/Loss'), 'realized_gain_dollars')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('Dividends Earned'), 'dividends_earned')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('52 week low'), 'market_data.fifty_two_week_low')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('52 week high'), 'market_data.fifty_two_week_high')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('Number of Transactions'))
->sortable(fn (Builder $query, string $direction) => $query->orderBy('num_transactions', $direction))
->label(fn ($row) => $row->num_transactions),
Column::make(__('Last Refreshed'), 'market_data.updated_at')
->sortable()
->format(fn ($value) => \Carbon\Carbon::parse($value)->diffForHumans()),
];
}
}
@@ -1,159 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Datatables;
use App\Models\Transaction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Number;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
class TransactionsTable extends DataTableComponent
{
public array $hiddenColumns = [];
public function mount(): void
{
//
}
public function builder(): Builder
{
return Transaction::query()
->with(['portfolio', 'market_data'])
->myTransactions()
->addSelect(['portfolio_id', 'transaction_type', 'split', 'cost_basis'])
->selectRaw('
(CASE
WHEN transaction_type = \'SELL\'
THEN COALESCE(transactions.sale_price, 0)
ELSE COALESCE(market_data.market_value, 0)
END) - COALESCE(transactions.cost_basis, 0) AS gain_dollars');
}
public function configure(): void
{
$this->hiddenColumns = ['name', 'cost_basis', 'gain_dollars'];
$this->setTableWrapperAttributes([
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'overflow-scroll',
]);
$this->setTableAttributes([
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'table',
]);
$this->setTheadAttributes([
'default' => false,
'default-styling' => true,
'default-colors' => false,
]);
$this->setThAttributes(function (Column $column) {
$attributes = [
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'text-xs font-medium whitespace-nowrap uppercase tracking-wider text-nowrap',
];
if (in_array($column->getField(), $this->hiddenColumns)) {
$attributes['class'] = $attributes['class'].' hidden md:table-cell';
}
return $attributes;
});
$this->setThSortButtonAttributes(fn () => [
'default' => false,
'default-styling' => true,
'default-colors' => false,
'class' => 'cursor-pointer',
]);
$this->setTbodyAttributes([
'default' => false,
'default-styling' => true,
'default-colors' => false,
]);
$this->setTrAttributes(fn () => [
'default' => false,
'default-styling' => true,
'default-colors' => false,
'class' => 'cursor-pointer hover:bg-neutral/25',
]);
$this->setTdAttributes(function (Column $column) {
$attributes = [
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'text-nowrap',
];
if (in_array($column->getField(), $this->hiddenColumns)) {
$attributes['class'] = $attributes['class'].' hidden md:table-cell';
}
return $attributes;
});
$this->setDefaultSort('date', 'desc');
$this->setPerPageAccepted([10, 15, 20]);
$this->setPerPage(15);
$this->setSearchDisabled();
$this->setColumnSelectDisabled();
$this->setPerPageVisibilityDisabled();
$this->setFooterDisabled();
$this->setPrimaryKey('id');
$this->setTableRowUrl(function ($row) {
return route('holding.show', ['portfolio' => $row->portfolio_id, 'symbol' => $row->symbol]);
})->setTableRowUrlTarget(function ($row) {
return 'navigate';
});
}
public function columns(): array
{
return [
Column::make(__('Date'), 'date')
->sortable()
->format(fn ($value) => \Carbon\Carbon::parse($value)->format('M d, Y')),
Column::make(__('Portfolio'), 'portfolio.title')
->sortable(),
Column::make(__('Symbol'), 'symbol')
->sortable(),
Column::make(__('Name'), 'market_data.name')
->sortable(),
Column::make(__('Type'), 'transaction_type')
->label(fn ($row) => view('components.ui.badge', [
'value' => $row->split ? 'SPLIT'
: ($row->reinvested_dividend
? 'REINVEST'
: $row->transaction_type),
'class' => ($row->transaction_type == 'BUY'
? 'badge-success'
: 'badge-error').' badge-sm mr-3',
]))
->sortable(fn (Builder $query, string $direction) => $query->orderBy('transaction_type', $direction)),
Column::make(__('Quantity'), 'quantity')
->sortable(),
Column::make(__('Cost Basis'), 'cost_basis')
->sortable(fn (Builder $query, string $direction) => $query->orderBy('cost_basis', $direction))
->label(fn ($row) => Number::currency($row->cost_basis ?? 0, $row->market_data->currency)),
Column::make(__('Gain/Loss'), 'gain_dollars')
->sortable(fn (Builder $query, string $direction) => $query->orderBy('gain_dollars', $direction))
->label(fn ($row) => Number::currency($row->gain_dollars ?? 0, $row->market_data->currency)),
];
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Tables;
use App\Models\Holding;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Support\Number;
use Livewire\Component;
class HoldingsTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public $portfolio;
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
public function table(Table $table): Table
{
return $table
->query(
Holding::query()
->portfolio($this->portfolio->id)
->withMarketData()
->withCount(['transactions as num_transactions' => function ($query) {
return $query->whereRaw('transactions.symbol = holdings.symbol');
}])
->withPerformance()
)
->defaultSort('symbol', 'asc')
->paginated(false)
->recordUrl(fn ($record) => route('holding.show', ['portfolio' => $record->portfolio_id, 'symbol' => $record->symbol]))
->columns([
TextColumn::make('symbol')
->label(__('Symbol'))
->sortable(),
TextColumn::make('market_data.name')
->label(__('Name'))
->sortable(),
TextColumn::make('quantity')
->label(__('Quantity'))
->sortable(),
TextColumn::make('average_cost_basis')
->label(__('Average Cost Basis'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('total_cost_basis')
->label(__('Total Cost Basis'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('market_data.market_value')
->label(__('Market Value'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('total_market_value')
->label(__('Total Market Value'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('market_gain_dollars')
->label(__('Market Gain/Loss'))
->sortable()
->html()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency).view('components.ui.gain-loss-arrow-badge', [
'costBasis' => $record->average_cost_basis,
'marketValue' => $record->market_data?->market_value,
'small' => true,
])->render()),
TextColumn::make('realized_gain_dollars')
->label(__('Realized Gain/Loss'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('dividends_earned')
->label(__('Dividends Earned'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('market_data.fifty_two_week_low')
->label(__('52 week low'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('market_data.fifty_two_week_high')
->label(__('52 week high'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('num_transactions')
->label(__('Number of Transactions'))
->sortable(),
TextColumn::make('market_data.updated_at')
->label(__('Last Refreshed'))
->sortable()
->since(),
])
->stackedOnMobile();
}
public function render(): string
{
return <<<'HTML'
<div>
{{ $this->table }}
</div>
HTML;
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Tables;
use App\Models\Transaction;
use Carbon\Carbon;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Number;
use Livewire\Component;
class TransactionsTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
public function table(Table $table): Table
{
return $table
->filters([
SelectFilter::make('transaction_type')
->options([
'BUY' => 'BUY',
'SELL' => 'SELL',
]),
SelectFilter::make('portfolio')
->relationship('portfolio', 'title', fn (Builder $query) => $query->myPortfolios())
->query(function (Builder $query, array $data): Builder {
return $query->when(
$data['value'],
fn (Builder $query, $value) => $query->portfolio($value)
);
}),
])
->deferFilters(false)
->query(
Transaction::query()
->with(['portfolio', 'market_data'])
->myTransactions()
->addSelect(['transactions.*'])
->selectRaw('
(CASE
WHEN transaction_type = \'SELL\'
THEN COALESCE(transactions.sale_price, 0)
ELSE COALESCE((SELECT market_value FROM market_data WHERE market_data.symbol = transactions.symbol LIMIT 1), 0)
END) - COALESCE(transactions.cost_basis, 0) AS gain_dollars')
)
->defaultSort('date', 'desc')
->extremePaginationLinks()
->paginated([10])
->defaultPaginationPageOption(10)
->recordUrl(fn ($record) => route('holding.show', ['portfolio' => $record->portfolio_id, 'symbol' => $record->symbol]))
->columns([
TextColumn::make('date')
->label(__('Date'))
->sortable()
->formatStateUsing(fn ($state) => Carbon::parse($state)->format('M d, Y')),
TextColumn::make('portfolio.title')
->label(__('Portfolio'))
->sortable(),
TextColumn::make('symbol')
->label(__('Symbol'))
->sortable(),
TextColumn::make('market_data.name')
->label(__('Name'))
->sortable(),
TextColumn::make('transaction_type')
->label(__('Type'))
->sortable()
->html()
->formatStateUsing(fn ($state, $record) => view('components.ui.badge', [
'value' => $record->split ? 'SPLIT' : ($record->reinvested_dividend ? 'REINVEST' : $record->transaction_type),
'class' => ($record->transaction_type == 'BUY' ? 'badge-success' : 'badge-error').' badge-sm mr-3',
])->render()),
TextColumn::make('quantity')
->label(__('Quantity'))
->sortable(),
TextColumn::make('cost_basis')
->label(__('Cost Basis'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data->currency)),
TextColumn::make('gain_dollars')
->label(__('Gain/Loss'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data->currency)),
]);
}
public function render(): string
{
return <<<'HTML'
<div>
{{ $this->table }}
</div>
HTML;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AgentConversationMessage extends Model
{
protected $table = 'agent_conversation_messages';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'id',
'conversation_id',
'user_id',
'agent',
'role',
'content',
];
public function conversation(): BelongsTo
{
return $this->belongsTo(ChatWithConversation::class, 'conversation_id');
}
}
-40
View File
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class AiChat extends Model
{
use HasUuids;
protected $fillable = [
'role',
'content',
];
protected $hidden = [];
protected static function boot()
{
parent::boot();
static::creating(function ($chat) {
$chat->user_id = auth()->user()->id;
});
}
public function user()
{
return $this->belongsTo(User::class);
}
public function chatable()
{
return $this->morphTo();
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Str;
class ChatWithConversation extends Model
{
protected $table = 'agent_conversations';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'id',
'user_id',
'title',
'chatable_type',
'chatable_id',
];
protected static function boot(): void
{
parent::boot();
static::creating(function (ChatWithConversation $model) {
if (empty($model->id)) {
$model->id = (string) Str::uuid7();
}
});
}
public function chatable(): MorphTo
{
return $this->morphTo();
}
public function messages(): HasMany
{
return $this->hasMany(AgentConversationMessage::class, 'conversation_id');
}
}
+8 -5
View File
@@ -146,16 +146,19 @@ class Dividend extends Model
'dividends.symbol',
'dividends.dividend_amount',
])->selectRaw("
(COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY'
AND date(transactions.date) <= date(dividends.date)
(COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY'
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL'
AND date(transactions.date) <= date(dividends.date)
- COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL'
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END), 0))
* dividends.dividend_amount
AS total_received
")->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id')
->join('holdings', function ($join) {
$join->on('transactions.portfolio_id', '=', 'holdings.portfolio_id')
->on('holdings.symbol', '=', 'dividends.symbol');
})
->where('dividends.symbol', $symbol)
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'dividends.dividend_amount_base');
+8 -10
View File
@@ -8,7 +8,9 @@ use App\Traits\HasMarketData;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -161,14 +163,10 @@ class Holding extends Model
->orderBy('date', 'DESC');
}
/**
* Related chats for holding
*
* @return void
*/
public function chats()
public function chatWithConversation(): MorphOne
{
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
return $this->morphOne(ChatWithConversation::class, 'chatable')
->where('user_id', auth()->id());
}
public function scopeWithMarketData($query)
@@ -449,7 +447,7 @@ class Holding extends Model
$this->save();
}
public function qtyOwned(?\Illuminate\Support\Carbon $date = null)
public function qtyOwned(?Carbon $date = null)
{
if ($date == null) {
$date = now();
@@ -470,8 +468,8 @@ class Holding extends Model
* @return void
*/
public function dailyPerformance(
?\Illuminate\Support\Carbon $start_date = null,
?\Illuminate\Support\Carbon $end_date = null,
?Carbon $start_date = null,
?Carbon $end_date = null,
) {
if ($start_date == null) {
$start_date = now();
+4 -7
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Interfaces\MarketData\MarketDataInterface;
use App\Models\ChatWithConversation;
use App\Notifications\InvitedOnboardingNotification;
use Carbon\CarbonPeriod;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@@ -68,14 +69,10 @@ class Portfolio extends Model
return $this->hasMany(DailyChange::class);
}
/**
* Related chats for portfolio
*
* @return void
*/
public function chats()
public function chatWithConversation(): \Illuminate\Database\Eloquent\Relations\MorphOne
{
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
return $this->morphOne(ChatWithConversation::class, 'chatable')
->where('user_id', auth()->id());
}
public function scopeMyPortfolios()
+15 -2
View File
@@ -4,6 +4,10 @@ declare(strict_types=1);
namespace App\Providers;
use App\Interfaces\MarketData\FallbackInterface;
use App\Interfaces\MarketData\MarketDataInterface;
use Filament\Support\Colors\Color;
use Filament\Support\Facades\FilamentColor;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Arr;
use Illuminate\Support\Number;
@@ -18,8 +22,8 @@ class AppServiceProvider extends ServiceProvider
public function register(): void
{
$this->app->bind(
\App\Interfaces\MarketData\MarketDataInterface::class,
\App\Interfaces\MarketData\FallbackInterface::class
MarketDataInterface::class,
FallbackInterface::class
);
}
@@ -28,6 +32,15 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
FilamentColor::register([
'primary' => Color::Stone,
'gray' => Color::Zinc,
'info' => Color::Blue,
'success' => Color::Emerald,
'warning' => Color::Amber,
'danger' => Color::Red,
]);
JsonResource::withoutWrapping();
Arr::macro('skipEmptyValues', function (array $array) {
+11
View File
@@ -0,0 +1,11 @@
{
"agents": [
"claude_code"
],
"editors": [
"claude_code",
"vscode"
],
"guidelines": [],
"herd_mcp": true
}
+8 -5
View File
@@ -16,21 +16,22 @@
"ext-mbstring": "*",
"ext-zip": "*",
"blade-ui-kit/blade-heroicons": "^2.6",
"filament/tables": "^5.0",
"finnhub/client": "master@dev",
"hackeresq/filter-models": "dev-main",
"investbrainapp/frankfurter-client": "dev-main",
"laravel/ai": "^0.2.5",
"laravel/fortify": "^1.30.0",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.0",
"laravel/socialite": "^5.16",
"laravel/tinker": "^2.9",
"league/flysystem-aws-s3-v3": "^3.0",
"livewire/livewire": "^3.5",
"livewire/livewire": "^4.0",
"livewire/volt": "^1.6",
"maatwebsite/excel": "^3.1",
"openai-php/client": "^0.10.3",
"predis/predis": "^2.2",
"rappasoft/laravel-livewire-tables": "^3.7",
"scheb/yahoo-finance-api": "^5.0",
"staudenmeir/eloquent-has-many-deep": "^1.20",
"symfony/cache": "^7.3",
@@ -38,8 +39,8 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/boost": "^1.8",
"laravel/pint": "^1.25",
"laravel/sail": "^1.26",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.0",
"phpunit/phpunit": "^11.0"
@@ -76,10 +77,12 @@
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
"@php artisan package:discover --ansi",
"@php artisan filament:upgrade"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
"@php artisan boost:update --ansi; exit 0"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
Generated
+2534 -951
View File
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Default AI Provider Names
|--------------------------------------------------------------------------
|
| Here you may specify which of the AI providers below should be the
| default for AI operations when no explicit provider is provided
| for the operation. This should be any provider defined below.
|
*/
'default' => env('CHAT_PROVIDER', 'openai'),
'default_for_images' => 'gemini',
'default_for_audio' => 'openai',
'default_for_transcription' => 'openai',
'default_for_embeddings' => 'openai',
'default_for_reranking' => 'cohere',
'models' => [
'text' => [
'default' => env('CHAT_MODEL', null),
],
],
/*
|--------------------------------------------------------------------------
| Caching
|--------------------------------------------------------------------------
|
| Below you may configure caching strategies for AI related operations
| such as embedding generation. You are free to adjust these values
| based on your application's available caching stores and needs.
|
*/
'caching' => [
'embeddings' => [
'cache' => false,
'store' => env('CACHE_STORE', 'database'),
],
],
/*
|--------------------------------------------------------------------------
| AI Providers
|--------------------------------------------------------------------------
|
| Below are each of your AI providers defined for this application. Each
| represents an AI provider and API key combination which can be used
| to perform tasks like text, image, and audio creation via agents.
|
*/
'providers' => [
'anthropic' => [
'driver' => 'anthropic',
'key' => env('ANTHROPIC_API_KEY'),
],
'azure' => [
'driver' => 'azure',
'key' => env('AZURE_OPENAI_API_KEY'),
'url' => env('AZURE_OPENAI_URL'),
'api_version' => env('AZURE_OPENAI_API_VERSION', '2024-10-21'),
'deployment' => env('AZURE_OPENAI_DEPLOYMENT', 'gpt-4o'),
'embedding_deployment' => env('AZURE_OPENAI_EMBEDDING_DEPLOYMENT', 'text-embedding-3-small'),
],
'cohere' => [
'driver' => 'cohere',
'key' => env('COHERE_API_KEY'),
],
'deepseek' => [
'driver' => 'deepseek',
'key' => env('DEEPSEEK_API_KEY'),
],
'eleven' => [
'driver' => 'eleven',
'key' => env('ELEVENLABS_API_KEY'),
],
'gemini' => [
'driver' => 'gemini',
'key' => env('GEMINI_API_KEY'),
],
'groq' => [
'driver' => 'groq',
'key' => env('GROQ_API_KEY'),
],
'jina' => [
'driver' => 'jina',
'key' => env('JINA_API_KEY'),
],
'mistral' => [
'driver' => 'mistral',
'key' => env('MISTRAL_API_KEY'),
],
'ollama' => [
'driver' => 'ollama',
'key' => env('OLLAMA_API_KEY', ''),
'url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'),
],
'openai' => [
'driver' => 'openai',
'key' => env('OPENAI_API_KEY'),
],
'openrouter' => [
'driver' => 'openrouter',
'key' => env('OPENROUTER_API_KEY'),
],
'voyageai' => [
'driver' => 'voyageai',
'key' => env('VOYAGEAI_API_KEY'),
],
'xai' => [
'driver' => 'xai',
'key' => env('XAI_API_KEY'),
],
],
];
+10
View File
@@ -151,6 +151,16 @@ return [
'label' => 'English (United States)',
'flag' => '',
],
[
'locale' => 'fr_CA',
'label' => 'French (Canada)',
'flag' => '',
],
[
'locale' => 'fr_FR',
'label' => 'French (France)',
'flag' => '',
],
[
'locale' => 'es_419',
'label' => 'Spanish (Latin America)',
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Laravel\Ai\Migrations\AiMigration;
return new class extends AiMigration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('agent_conversations', function (Blueprint $table) {
$table->string('id', 36)->primary();
$table->foreignIdFor(User::class, 'user_id')->constrained()->onDelete('cascade')->nullable();
$table->string('title');
$table->timestamps();
$table->index(['user_id', 'updated_at']);
});
Schema::create('agent_conversation_messages', function (Blueprint $table) {
$table->string('id', 36)->primary();
$table->string('conversation_id', 36)->index();
$table->foreignIdFor(User::class, 'user_id')->constrained()->onDelete('cascade')->nullable();
$table->string('agent');
$table->string('role', 25);
$table->text('content');
$table->text('attachments');
$table->text('tool_calls');
$table->text('tool_results');
$table->text('usage');
$table->text('meta');
$table->timestamps();
$table->index(['conversation_id', 'user_id', 'updated_at'], 'conversation_index');
$table->index(['user_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('agent_conversations');
Schema::dropIfExists('agent_conversation_messages');
}
};
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
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::table('agent_conversations', function (Blueprint $table) {
$table->nullableUuidMorphs('chatable');
$table->unique(['user_id', 'chatable_type', 'chatable_id'], 'chat_with_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('agent_conversations', function (Blueprint $table) {
$table->dropUnique('chat_with_unique');
$table->dropMorphs('chatable');
});
}
};
+3 -3
View File
@@ -1,5 +1,5 @@
# Stage 1: Build stage
FROM php:8.3-fpm AS builder
FROM php:8.4-fpm AS builder
ENV DEBIAN_FRONTEND=noninteractive
ENV APP_NAME=Investbrain
@@ -27,7 +27,7 @@ RUN apt-get update && apt-get upgrade -y \
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd zip
&& docker-php-ext-install -j$(nproc) gd zip intl
# Copy application files
COPY . .
@@ -39,7 +39,7 @@ RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local
&& rm -rf node_modules
# Stage 2: Production stage
FROM php:8.3-fpm-alpine
FROM php:8.4-fpm-alpine
# Set the working directory
WORKDIR /var/app
+386
View File
@@ -0,0 +1,386 @@
{
"Unknown": "Inconnu",
"Done.": "Terminé.",
"Saved.": "Enregistré.",
"Created.": "Créé.",
"Enable": "Activer",
"Disable": "Désactiver",
"Log Out": "Se déconnecter",
"Import": "Importer",
"Export": "Exporter",
"Log in": "Se connecter",
"Register": "S'inscrire",
"Create": "Créer",
"Update": "Mettre à jour",
"Cancel": "Annuler",
"Save": "Enregistrer",
"Close": "Fermer",
"Dismiss": "Ignorer",
"or": "ou",
"and": "et",
"Yes": "Oui",
"you": "vous",
"You": "Vous",
"Nothing to show here yet": "Rien à afficher pour l'instant",
"Try again": "Réessayer",
"Hang on! You're doing that too much.": "Attendez ! Vous faites cela trop souvent.",
"Delete Account": "Supprimer le compte",
"Permanently delete your account.": "Supprimez définitivement votre compte.",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Une fois votre compte supprimé, toutes ses ressources et données seront définitivement supprimées. Avant de supprimer votre compte, veuillez télécharger toutes les données ou informations que vous souhaitez conserver.",
"Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Êtes-vous sûr de vouloir supprimer votre compte ? Une fois votre compte supprimé, toutes ses ressources et données seront définitivement supprimées. Veuillez saisir votre mot de passe pour confirmer la suppression définitive de votre compte.",
"Two Factor Authentication": "Authentification à deux facteurs",
"Add additional security to your account using two factor authentication.": "Ajoutez une sécurité supplémentaire à votre compte grâce à l'authentification à deux facteurs.",
"Finish enabling two factor authentication.": "Terminez l'activation de l'authentification à deux facteurs.",
"You have enabled two factor authentication.": "Vous avez activé l'authentification à deux facteurs.",
"You have not enabled two factor authentication.": "Vous n'avez pas activé l'authentification à deux facteurs.",
"When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.": "Lorsque l'authentification à deux facteurs est activée, un jeton sécurisé et aléatoire vous sera demandé lors de la connexion. Vous pouvez récupérer ce jeton depuis l'application Google Authenticator de votre téléphone.",
"To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code.": "Pour terminer l'activation de l'authentification à deux facteurs, scannez le QR code suivant avec l'application d'authentification de votre téléphone ou saisissez la clé de configuration et fournissez le code OTP généré.",
"Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.": "L'authentification à deux facteurs est maintenant activée. Scannez le QR code suivant avec l'application d'authentification de votre téléphone ou saisissez la clé de configuration.",
"Setup Key": "Clé de configuration",
"Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.": "Conservez ces codes de récupération dans un gestionnaire de mots de passe sécurisé. Ils peuvent être utilisés pour récupérer l'accès à votre compte si votre appareil d'authentification à deux facteurs est perdu.",
"Regenerate Recovery Codes": "Régénérer les codes de récupération",
"Show Recovery Codes": "Afficher les codes de récupération",
"Update Password": "Modifier le mot de passe",
"Ensure your account is using a long, random password to stay secure.": "Assurez-vous que votre compte utilise un mot de passe long et aléatoire pour rester sécurisé.",
"Current Password": "Mot de passe actuel",
"New Password": "Nouveau mot de passe",
"Token Name": "Nom du jeton",
"Permissions": "Permissions",
"Profile Information": "Informations du profil",
"Your :provider account has been connected.": "Votre compte :provider a été connecté.",
"Account already exists. Check your email to connect your :provider account.": "Un compte existe déjà. Vérifiez votre e-mail pour connecter votre compte :provider.",
"Could not login using :provider. Try again later.": "Impossible de se connecter avec :provider. Réessayez plus tard.",
"Update your account's profile information and email address.": "Mettez à jour les informations de profil et l'adresse e-mail de votre compte.",
"Photo": "Photo",
"Select A New Photo": "Sélectionner une nouvelle photo",
"Remove Photo": "Supprimer la photo",
"API Tokens": "Jetons API",
"Manage API Tokens": "Gérer les jetons API",
"Create API Token": "Créer un jeton API",
"You may delete any of your existing tokens if they are no longer needed.": "Vous pouvez supprimer tout jeton existant s'il n'est plus nécessaire.",
"Last used": "Dernière utilisation",
"Delete": "Supprimer",
"API Token": "Jeton API",
"Please copy your new API token. For your security, it won't be shown again.": "Veuillez copier votre nouveau jeton API. Pour votre sécurité, il ne sera plus affiché.",
"Copy to clipboard": "Copier dans le presse-papiers",
"Successfully copied!": "Copié avec succès !",
"API Token Permissions": "Permissions du jeton API",
"API tokens allow third-party services to authenticate with Investbrain on your behalf.": "Les jetons API permettent à des services tiers de s'authentifier auprès d'Investbrain en votre nom.",
"Delete API Token": "Supprimer le jeton API",
"Are you sure you would like to delete this API token?": "Êtes-vous sûr de vouloir supprimer ce jeton API ?",
"This is a secure area of the application. Please confirm your password before continuing.": "Il s'agit d'une zone sécurisée de l'application. Veuillez confirmer votre mot de passe avant de continuer.",
"Password": "Mot de passe",
"Confirm": "Confirmer",
"Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.": "Mot de passe oublié ? Pas de problème. Indiquez-nous votre adresse e-mail et nous vous enverrons un lien de réinitialisation du mot de passe.",
"Email": "E-mail",
"Email Password Reset Link": "Envoyer le lien de réinitialisation du mot de passe",
"Remember me": "Se souvenir de moi",
"Forgot your password?": "Mot de passe oublié ?",
"Name": "Nom",
"Confirm Password": "Confirmer le mot de passe",
"I agree to the :terms_of_service and :privacy_policy": "J'accepte les :terms_of_service et la :privacy_policy",
"Terms of Service": "Conditions d'utilisation",
"Privacy Policy": "Politique de confidentialité",
"Sign up with email": "S'inscrire avec un e-mail",
"Login with": "Se connecter avec",
"Already registered?": "Déjà inscrit ?",
"Reset Password": "Réinitialiser le mot de passe",
"Please confirm access to your account by entering the authentication code provided by your authenticator application.": "Veuillez confirmer l'accès à votre compte en saisissant le code d'authentification fourni par votre application d'authentification.",
"Please confirm access to your account by entering one of your emergency recovery codes.": "Veuillez confirmer l'accès à votre compte en saisissant l'un de vos codes de récupération d'urgence.",
"Code": "Code",
"Recovery Code": "Code de récupération",
"Use a recovery code": "Utiliser un code de récupération",
"Use an authentication code": "Utiliser un code d'authentification",
"Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn't receive the email, we will gladly send you another.": "Avant de continuer, pourriez-vous vérifier votre adresse e-mail en cliquant sur le lien que nous venons de vous envoyer ? Si vous n'avez pas reçu l'e-mail, nous vous en enverrons un autre avec plaisir.",
"A new verification link has been sent to the email address you provided in your profile settings.": "Un nouveau lien de vérification a été envoyé à l'adresse e-mail que vous avez fournie dans les paramètres de votre profil.",
"Resend Verification Email": "Renvoyer l'e-mail de vérification",
"Edit Profile": "Modifier le profil",
"Upload or recover your Investbrain portfolio and holdings.": "Téléchargez ou récupérez votre portefeuille et vos avoirs Investbrain.",
"Download all of your portfolios and transactions.": "Téléchargez tous vos portefeuilles et transactions.",
"Import / Export Data": "Importer / Exporter des données",
"Successfully imported!": "Importé avec succès !",
"Select a file": "Sélectionner un fichier",
"Download Export": "Télécharger l'export",
"Click to download import template.": "Cliquez pour télécharger le modèle d'importation.",
"Download import template.": "Télécharger le modèle d'importation.",
"Your email address is unverified.": "Votre adresse e-mail n'est pas vérifiée.",
"Click here to re-send the verification email.": "Cliquez ici pour renvoyer l'e-mail de vérification.",
"A new verification link has been sent to your email address.": "Un nouveau lien de vérification a été envoyé à votre adresse e-mail.",
"The provided password does not match your current password.": "Le mot de passe fourni ne correspond pas à votre mot de passe actuel.",
"Documentation": "Documentation",
"We're open source!": "Nous sommes open source !",
"Toggle Theme": "Changer de thème",
"Toggle Sidebar": "Afficher/masquer la barre latérale",
"Dashboard": "Tableau de bord",
"Gain/Loss": "Gain/Perte",
"Market Gain/Loss": "Gain/Perte sur le marché",
"Total Cost Basis": "Coût de base total",
"Total Sale Price": "Prix de vente total",
"Total Market Value": "Valeur marchande totale",
"Realized Gain/Loss": "Gain/Perte réalisé(e)",
"Dividends Earned": "Dividendes perçus",
"Dividends": "Dividendes",
"Holding Options": "Options de l'avoir",
"Holding options saved": "Options de l'avoir enregistrées",
"Reinvest Dividends": "Réinvestir les dividendes",
"Automatically generate buy transactions for any dividends earned": "Générer automatiquement des transactions d'achat pour les dividendes perçus",
"Split": "Division",
"Splits": "Divisions",
"No splits for :symbol yet": "Aucune division pour :symbol pour l'instant",
"Distribution Date": "Date de distribution",
"My portfolios": "Mes portefeuilles",
"Create your first portfolio!": "Créez votre premier portefeuille !",
"Wishlist": "Liste de souhaits",
"Top performers": "Meilleures performances",
"Top headlines": "Principales actualités",
"Click or press :key to search": "Cliquez ou appuyez sur :key pour rechercher",
"Click to search": "Cliquez pour rechercher",
"Search holdings, portfolios, or anything else...": "Rechercher des avoirs, des portefeuilles ou autre chose...",
"Darn! Nothing found for that search.": "Zut ! Aucun résultat pour cette recherche.",
"Portfolio": "Portefeuille",
"Portfolios": "Portefeuilles",
"Create Portfolio": "Créer un portefeuille",
"Transactions": "Transactions",
"Reporting": "Rapports",
"Manage Profile": "Gérer le profil",
"Symbol": "Symbole",
"Quantity": "Quantité",
"Quantity Owned": "Quantité détenue",
"The quantity must not be greater than the available quantity.": "La quantité ne doit pas être supérieure à la quantité disponible.",
"Average Cost Basis": "Coût de base moyen",
"Market Value": "Valeur marchande",
"52 week": "52 semaines",
"52 week low": "Plus bas sur 52 semaines",
"52 week high": "Plus haut sur 52 semaines",
"Forward PE": "PER prospectif",
"Trailing PE": "PER historique",
"Market Cap": "Capitalisation boursière",
"Book Value": "Valeur comptable",
"Dividend Yield": "Rendement du dividende",
"Last Dividend Paid": "Dernier dividende versé",
"Ex Dividend Date": "Date ex-dividende",
"No dividends for :symbol yet": "Aucun dividende pour :symbol pour l'instant",
"Number of Transactions": "Nombre de transactions",
"Last Refreshed": "Dernière mise à jour",
"Portfolio updated": "Portefeuille mis à jour",
"Portfolio created": "Portefeuille créé",
"Portfolio deleted": "Portefeuille supprimé",
"Title": "Titre",
"Notes": "Notes",
"Treat this portfolio as a \"wishlist\" (holdings will be excluded from realized gains, unrealized gains, and dividends)": "Traiter ce portefeuille comme une « liste de souhaits » (les avoirs seront exclus des gains réalisés, des gains non réalisés et des dividendes)",
"Delete Portfolio": "Supprimer le portefeuille",
"Are you sure you want to delete this portfolio? Once a portfolio is deleted, all of its holdings and other data will be permanently deleted.": "Êtes-vous sûr de vouloir supprimer ce portefeuille ? Une fois un portefeuille supprimé, tous ses avoirs et autres données seront définitivement supprimés.",
"Transaction updated": "Transaction mise à jour",
"Transaction created": "Transaction créée",
"Transaction deleted": "Transaction supprimée",
"Transaction Type": "Type de transaction",
"Transaction Date": "Date de transaction",
"Delete Transaction": "Supprimer la transaction",
"Are you sure you want to delete this transaction?": "Êtes-vous sûr de vouloir supprimer cette transaction ?",
"Cost Basis": "Coût de base",
"Sale Price": "Prix de vente",
"Market Gain": "Gain sur le marché",
"Realized Gains": "Gains réalisés",
"Performance": "Performance",
"Reset chart": "Réinitialiser le graphique",
"Choose time period": "Choisir une période",
"Manage Portfolio": "Gérer le portefeuille",
"Create Transaction": "Créer une transaction",
"Manage Transaction": "Gérer la transaction",
"Holding": "Avoir",
"Holdings": "Avoirs",
"Recent activity": "Activité récente",
"All Transactions": "Toutes les transactions",
"validation.accepted": "Ce champ doit être accepté.",
"validation.accepted_if": "Ce champ doit être accepté lorsque :other est :value.",
"validation.active_url": "Ce champ n'est pas une URL valide.",
"validation.after": "Ce champ doit être une date postérieure à :date.",
"validation.after_or_equal": "Ce champ doit être une date postérieure ou égale à :date.",
"validation.alpha": "Ce champ ne doit contenir que des lettres.",
"validation.alpha_dash": "Ce champ ne doit contenir que des lettres, des chiffres, des tirets et des underscores.",
"validation.alpha_num": "Ce champ ne doit contenir que des lettres et des chiffres.",
"validation.array": "Ce champ doit être un tableau.",
"validation.ascii": "Ce champ ne doit contenir que des caractères alphanumériques et des symboles à un seul octet.",
"validation.attached": "Ce champ est déjà attaché.",
"validation.before": "Ce champ doit être une date antérieure à :date.",
"validation.before_or_equal": "Ce champ doit être une date antérieure ou égale à :date.",
"validation.between.array": "Ce champ doit contenir entre :min et :max éléments.",
"validation.between.file": "Ce champ doit être compris entre :min et :max kilo-octets.",
"validation.between.numeric": "Ce champ doit être compris entre :min et :max.",
"validation.between.string": "Ce champ doit contenir entre :min et :max caractères.",
"validation.boolean": "Ce champ doit être vrai ou faux.",
"validation.can": "Ce champ contient une valeur non autorisée.",
"validation.confirmed": "La confirmation de ce champ ne correspond pas.",
"validation.contains": "Ce champ est absent d'une valeur requise.",
"validation.date": "Ce champ n'est pas une date valide.",
"validation.date_equals": "Ce champ doit être une date égale à :date.",
"validation.date_format": "Ce champ ne correspond pas au format :format.",
"validation.decimal": "Ce champ doit avoir :decimal décimales.",
"validation.declined": "Ce champ doit être refusé.",
"validation.declined_if": "Ce champ doit être refusé lorsque :other est :value.",
"validation.different": "Ce champ et :other doivent être différents.",
"validation.digits": "Ce champ doit contenir :digits chiffres.",
"validation.digits_between": "Ce champ doit contenir entre :min et :max chiffres.",
"validation.dimensions": "Ce champ a des dimensions d'image invalides.",
"validation.distinct": "Ce champ a une valeur en double.",
"validation.doesnt_end_with": "Ce champ ne doit pas se terminer par l'un des éléments suivants : :values.",
"validation.doesnt_start_with": "Ce champ ne doit pas commencer par l'un des éléments suivants : :values.",
"validation.email": "Ce champ doit être une adresse e-mail valide.",
"validation.ends_with": "Ce champ doit se terminer par l'un des éléments suivants : :values.",
"validation.enum": "La valeur sélectionnée ne fait pas partie des valeurs autorisées.",
"validation.exists": "La valeur sélectionnée n'existe pas.",
"validation.extensions": "Ce champ doit avoir l'une des extensions suivantes : :values.",
"validation.file": "Ce champ doit être un fichier.",
"validation.filled": "Ce champ doit avoir une valeur.",
"validation.gt.array": "Ce champ doit avoir plus de :value éléments.",
"validation.gt.file": "Ce champ doit être supérieur à :value kilo-octets.",
"validation.gt.numeric": "Ce champ doit être supérieur à :value.",
"validation.gt.string": "Ce champ doit contenir plus de :value caractères.",
"validation.gte.array": "Ce champ doit avoir :value éléments ou plus.",
"validation.gte.file": "Ce champ doit être supérieur ou égal à :value kilo-octets.",
"validation.gte.numeric": "Ce champ doit être supérieur ou égal à :value.",
"validation.gte.string": "Ce champ doit contenir :value caractères ou plus.",
"validation.hex_color": "Ce champ doit être une couleur hexadécimale valide.",
"validation.image": "Ce champ doit être une image.",
"validation.in": "La valeur sélectionnée ne fait pas partie des valeurs autorisées.",
"validation.in_array": "Ce champ n'existe pas dans :other.",
"validation.integer": "Ce champ doit être un entier.",
"validation.ip": "Ce champ doit être une adresse IP valide.",
"validation.ipv4": "Ce champ doit être une adresse IPv4 valide.",
"validation.ipv6": "Ce champ doit être une adresse IPv6 valide.",
"validation.json": "Ce champ doit être une chaîne JSON valide.",
"validation.list": "Ce champ doit être une liste.",
"validation.lowercase": "Ce champ doit être en minuscules.",
"validation.lt.array": "Ce champ doit avoir moins de :value éléments.",
"validation.lt.file": "Ce champ doit être inférieur à :value kilo-octets.",
"validation.lt.numeric": "Ce champ doit être inférieur à :value.",
"validation.lt.string": "Ce champ doit contenir moins de :value caractères.",
"validation.lte.array": "Ce champ ne doit pas avoir plus de :value éléments.",
"validation.lte.file": "Ce champ doit être inférieur ou égal à :value kilo-octets.",
"validation.lte.numeric": "Ce champ doit être inférieur ou égal à :value.",
"validation.lte.string": "Ce champ doit contenir :value caractères ou moins.",
"validation.mac_address": "Ce champ doit être une adresse MAC valide.",
"validation.max.array": "Ce champ ne doit pas avoir plus de :max éléments.",
"validation.max.file": "Ce champ ne doit pas être supérieur à :max kilo-octets.",
"validation.max.numeric": "Ce champ ne doit pas être supérieur à :max.",
"validation.max.string": "Ce champ ne doit pas contenir plus de :max caractères.",
"validation.max_digits": "Ce champ ne doit pas avoir plus de :max chiffres.",
"validation.mimes": "Ce champ doit être un fichier de type : :values.",
"validation.mimetypes": "Ce champ doit être un fichier de type : :values.",
"validation.min.array": "Ce champ doit avoir au moins :min éléments.",
"validation.min.file": "Ce champ doit faire au moins :min kilo-octets.",
"validation.min.numeric": "Ce champ doit être au moins :min.",
"validation.min.string": "Ce champ doit contenir au moins :min caractères.",
"validation.min_digits": "Ce champ doit avoir au moins :min chiffres.",
"validation.missing": "Ce champ doit être absent.",
"validation.missing_if": "Ce champ doit être absent lorsque :other est :value.",
"validation.missing_unless": "Ce champ doit être absent sauf si :other est :value.",
"validation.missing_with": "Ce champ doit être absent lorsque :values est présent.",
"validation.missing_with_all": "Ce champ doit être absent lorsque :values sont présents.",
"validation.multiple_of": "Ce champ doit être un multiple de :value.",
"validation.not_in": "La valeur sélectionnée ne doit pas être dans la liste.",
"validation.not_regex": "Le format de ce champ est invalide.",
"validation.numeric": "Ce champ doit être un nombre.",
"validation.password.letters": "Ce champ doit contenir au moins une lettre.",
"validation.password.mixed": "Ce champ doit contenir au moins une lettre majuscule et une lettre minuscule.",
"validation.password.numbers": "Ce champ doit contenir au moins un chiffre.",
"validation.password.symbols": "Ce champ doit contenir au moins un symbole.",
"validation.password.uncompromised": "Ce champ est apparu dans une fuite de données. Veuillez choisir un autre champ.",
"validation.present": "Ce champ doit être présent.",
"validation.present_if": "Ce champ doit être présent lorsque :other est :value.",
"validation.present_unless": "Ce champ doit être présent sauf si :other est :value.",
"validation.present_with": "Ce champ doit être présent lorsque :values est présent.",
"validation.present_with_all": "Ce champ doit être présent lorsque :values sont présents.",
"validation.prohibited": "Ce champ est interdit.",
"validation.prohibited_if": "Ce champ est interdit lorsque :other est :value.",
"validation.prohibited_unless": "Ce champ est interdit sauf si :other est dans :values.",
"validation.prohibits": "Ce champ interdit la présence de :other.",
"validation.regex": "Le format de ce champ est invalide.",
"validation.relatable": "Ce champ ne peut pas être associé à cette ressource.",
"validation.required": "Ce champ est obligatoire.",
"validation.required_array_keys": "Ce champ doit contenir des entrées pour : :values.",
"validation.required_if": "Ce champ est obligatoire lorsque :other est :value.",
"validation.required_if_accepted": "Ce champ est obligatoire lorsque :other est accepté.",
"validation.required_if_declined": "Ce champ est obligatoire lorsque :other est refusé.",
"validation.required_unless": "Ce champ est obligatoire sauf si :other est dans :values.",
"validation.required_with": "Ce champ est obligatoire lorsque :values est présent.",
"validation.required_with_all": "Ce champ est obligatoire lorsque :values sont présents.",
"validation.required_without": "Ce champ est obligatoire lorsque :values est absent.",
"validation.required_without_all": "Ce champ est obligatoire lorsque aucun des éléments :values n'est présent.",
"validation.same": "Ce champ et :other doivent correspondre.",
"validation.size.array": "Ce champ doit contenir :size éléments.",
"validation.size.file": "Ce champ doit faire :size kilo-octets.",
"validation.size.numeric": "Ce champ doit être :size.",
"validation.size.string": "Ce champ doit contenir :size caractères.",
"validation.starts_with": "Ce champ doit commencer par l'un des éléments suivants : :values.",
"validation.string": "Ce champ doit être une chaîne de caractères.",
"validation.timezone": "Ce champ doit être un fuseau horaire valide.",
"validation.ulid": "Ce champ doit être un ULID valide.",
"validation.unique": "Cette valeur est déjà utilisée.",
"validation.uploaded": "Ce champ n'a pas pu être téléchargé.",
"validation.uppercase": "Ce champ doit être en majuscules.",
"validation.url": "Ce champ doit être une URL valide.",
"validation.uuid": "Ce champ doit être un UUID valide.",
"passwords.reset": "Votre mot de passe a été réinitialisé.",
"passwords.sent": "Nous vous avons envoyé votre lien de réinitialisation du mot de passe par e-mail.",
"passwords.throttled": "Veuillez patienter avant de réessayer.",
"passwords.token": "Ce jeton de réinitialisation du mot de passe est invalide.",
"passwords.user": "Nous ne trouvons pas d'utilisateur avec cette adresse e-mail.",
"pagination.previous": "&laquo; Précédent",
"pagination.next": "Suivant &raquo;",
"auth.failed": "Ces identifiants ne correspondent pas à nos enregistrements.",
"auth.password": "Le mot de passe fourni est incorrect.",
"auth.throttle": "Trop de tentatives de connexion. Veuillez réessayer dans :seconds secondes.",
"Add People": "Ajouter des personnes",
"People with access": "Personnes ayant accès",
"Owner": "Propriétaire",
"Read only": "Lecture seule",
"Full access": "Accès complet",
"You do not have permission to manage transactions for this portfolio": "Vous n'avez pas la permission de gérer les transactions de ce portefeuille",
"Updated user's access permission to portfolio": "Permission d'accès de l'utilisateur au portefeuille mise à jour",
"Removed user's access to portfolio": "Accès de l'utilisateur au portefeuille supprimé",
"Shared portfolio with user": "Portefeuille partagé avec l'utilisateur",
"Share Portfolio": "Partager le portefeuille",
"Type an email address to share portfolio": "Saisissez une adresse e-mail pour partager le portefeuille",
"Grant full access": "Accorder l'accès complet",
"Allow this user to manage portfolio details and create or update transactions": "Autoriser cet utilisateur à gérer les détails du portefeuille et à créer ou mettre à jour des transactions",
"Share": "Partager",
"Remove Access": "Supprimer l'accès",
"By removing this person's access, they will no longer be able to view this portfolio. They will lose access immediately.": "En supprimant l'accès de cette personne, elle ne pourra plus consulter ce portefeuille. Elle perdra l'accès immédiatement.",
"Hey again!": "Ravi de vous revoir !",
"Before you can get started with Investbrain, let's complete your profile:": "Avant de commencer avec Investbrain, complétons votre profil :",
"Get Started": "Commencer",
"You do not have access to that portfolio.": "Vous n'avez pas accès à ce portefeuille.",
"Import starting...": "Importation en cours de démarrage...",
"Import is in progress...": "Importation en cours...",
"Importing portfolios...": "Importation des portefeuilles...",
"Preparing to import transactions...": "Préparation de l'importation des transactions...",
"Importing transactions (Batch :currentBatch of :totalBatches)...": "Importation des transactions (Lot :currentBatch sur :totalBatches)...",
"Preparing to import daily changes...": "Préparation de l'importation des variations journalières...",
"Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importation des variations journalières (Lot :currentBatch sur :totalBatches)...",
"Importing configurations...": "Importation des configurations...",
"Import completed successfully!": "Importation terminée avec succès !",
"Your import will continue in the background": "Votre importation continuera en arrière-plan",
"AI Chat": "Chat IA",
"Hi, how can I help?": "Bonjour, comment puis-je vous aider ?",
"Have a question? AI might be able to help...": "Vous avez une question ? L'IA pourrait peut-être vous aider...",
"Feel free to ask me a question!": "N'hésitez pas à me poser une question !",
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Les conseils générés par l'IA peuvent contenir des erreurs. Utilisez à vos propres risques. Consultez toujours un conseiller en investissement agréé.",
"Currency": "Devise",
"Locale Options": "Options de localisation",
"Adjust localization options for your preferred region.": "Ajustez les options de localisation pour votre région préférée.",
"Locale": "Locale",
"Display Currency": "Devise d'affichage"
}
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "investbrain",
"name": "relock-npm-lock-v2-sQGmDe",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@alpinejs/focus": "^3.15.0",
"@alpinejs/persist": "^3.14.9",
"@alpinejs/persist": "^3.15.4",
"@alpinejs/resize": "^3.14.9",
"alpinejs": "^3.14.9",
"apexcharts": "^3.51.0"
@@ -35,9 +35,9 @@
}
},
"node_modules/@alpinejs/persist": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/@alpinejs/persist/-/persist-3.15.0.tgz",
"integrity": "sha512-SmW1DWn9FRflfPqZZtpTq+2uXDq/ohbSiKmYg6HXX1UxQnsaSkTT0HT72SQcbqOC3WmIUF28CberBnwiWoqmpw==",
"version": "3.15.4",
"resolved": "https://registry.npmjs.org/@alpinejs/persist/-/persist-3.15.4.tgz",
"integrity": "sha512-PdSpPTh8sUN8a3S2BoJWpXbod68RqjuW9QGtDp0LJclAFERVjLLx2K5YzQSSg5IDQJV9OMiknSpzcEYeZPwLNw==",
"license": "MIT"
},
"node_modules/@alpinejs/resize": {
+1 -1
View File
@@ -19,7 +19,7 @@
},
"dependencies": {
"@alpinejs/focus": "^3.15.0",
"@alpinejs/persist": "^3.14.9",
"@alpinejs/persist": "^3.15.4",
"@alpinejs/resize": "^3.14.9",
"alpinejs": "^3.14.9",
"apexcharts": "^3.51.0"
+1
View File
@@ -0,0 +1 @@
(()=>{var n=({livewireId:e})=>({actionNestingIndex:null,init(){window.addEventListener("sync-action-modals",t=>{t.detail.id===e&&this.syncActionModals(t.detail.newActionNestingIndex,t.detail.shouldOverlayParentActions??!1)})},syncActionModals(t,i=!1){if(this.actionNestingIndex===t){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}let s=this.actionNestingIndex!==null&&t!==null&&t>this.actionNestingIndex;if(this.actionNestingIndex!==null&&!(i&&s)&&this.closeModal(),this.actionNestingIndex=t,this.actionNestingIndex!==null){if(!this.$el.querySelector(`#${this.generateModalId(t)}`)){this.$nextTick(()=>this.openModal());return}this.openModal()}},generateModalId(t){return`fi-${e}-action-`+t},openModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:t}}))},closeModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:t}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",n)});})();
@@ -0,0 +1 @@
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",unsubscribeLivewireHook:null,visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{e.component.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))},destroy(){this.unsubscribeLivewireHook?.()}}}export{c as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
function a({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{if(!Array.isArray(e))return;let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{a as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
function s({state:n,splitKeys:a}){return{newTag:"",state:n,createTag(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag(t){this.state=this.state.filter(e=>e!==t)},reorderTags(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(t){["Enter",...a].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(a.length===0){this.createTag();return}let t=a.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{s as default};
@@ -0,0 +1 @@
function n({initialHeight:e,shouldAutosize:i,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=e+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let t=this.$el.style.height;this.$el.style.height="0px";let r=this.$el.scrollHeight;this.$el.style.height=t;let l=parseFloat(e)*parseFloat(getComputedStyle(document.documentElement).fontSize),s=Math.max(r,l)+"px";this.wrapperEl.style.height!==s&&(this.wrapperEl.style.height=s)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{n as default};
@@ -0,0 +1 @@
(()=>{function c(s,t=()=>{}){let i=!1;return function(){i?t.apply(this,arguments):(i=!0,s.apply(this,arguments))}}var d=s=>{s.data("notificationComponent",({notification:t})=>({isShown:!1,computedStyle:null,transitionDuration:null,transitionEasing:null,unsubscribeLivewireHook:null,init(){this.computedStyle=window.getComputedStyle(this.$el),this.transitionDuration=parseFloat(this.computedStyle.transitionDuration)*1e3,this.transitionEasing=this.computedStyle.transitionTimingFunction,this.configureTransitions(),this.configureAnimations(),t.duration&&t.duration!=="persistent"&&setTimeout(()=>{if(!this.$el.matches(":hover")){this.close();return}this.$el.addEventListener("mouseleave",()=>this.close())},t.duration),this.isShown=!0},configureTransitions(){let i=this.computedStyle.display,e=()=>{s.mutateDom(()=>{this.$el.style.setProperty("display",i),this.$el.style.setProperty("visibility","visible")}),this.$el._x_isShown=!0},o=()=>{s.mutateDom(()=>{this.$el._x_isShown?this.$el.style.setProperty("visibility","hidden"):this.$el.style.setProperty("display","none")})},r=c(n=>n?e():o(),n=>{this.$el._x_toggleAndCascadeWithTransitions(this.$el,n,e,o)});s.effect(()=>r(this.isShown))},configureAnimations(){let i;this.unsubscribeLivewireHook=Livewire.interceptMessage(({onFinish:e,onSuccess:o})=>{requestAnimationFrame(()=>{let r=()=>this.$el.getBoundingClientRect().top,n=r();e(()=>{i=()=>{this.isShown&&this.$el.animate([{transform:`translateY(${n-r()}px)`},{transform:"translateY(0px)"}],{duration:this.transitionDuration,easing:this.transitionEasing})},this.$el.getAnimations().forEach(l=>l.finish())}),o(({payload:l})=>{l?.snapshot?.data?.isFilamentNotificationsComponent&&typeof i=="function"&&i()})})})},close(){this.isShown=!1,setTimeout(()=>window.dispatchEvent(new CustomEvent("notificationClosed",{detail:{id:t.id}})),this.transitionDuration)},markAsRead(){window.dispatchEvent(new CustomEvent("markedNotificationAsRead",{detail:{id:t.id}}))},markAsUnread(){window.dispatchEvent(new CustomEvent("markedNotificationAsUnread",{detail:{id:t.id}}))},destroy(){this.unsubscribeLivewireHook?.()}}))};var h=class{constructor(){return this.id(crypto.randomUUID?.()??"10000000-1000-4000-8000-100000000000".replace(/[018]/g,t=>(+t^crypto.getRandomValues(new Uint8Array(1))[0]&15>>+t/4).toString(16))),this}id(t){return this.id=t,this}title(t){return this.title=t,this}body(t){return this.body=t,this}actions(t){return this.actions=t,this}status(t){return this.status=t,this}color(t){return this.color=t,this}icon(t){return this.icon=t,this}iconColor(t){return this.iconColor=t,this}duration(t){return this.duration=t,this}seconds(t){return this.duration(t*1e3),this}persistent(){return this.duration("persistent"),this}danger(){return this.status("danger"),this}info(){return this.status("info"),this}success(){return this.status("success"),this}warning(){return this.status("warning"),this}view(t){return this.view=t,this}viewData(t){return this.viewData=t,this}send(){return window.dispatchEvent(new CustomEvent("notificationSent",{detail:{notification:this}})),this}},a=class{constructor(t){return this.name(t),this}name(t){return this.name=t,this}color(t){return this.color=t,this}dispatch(t,i){return this.event(t),this.eventData(i),this}dispatchSelf(t,i){return this.dispatch(t,i),this.dispatchDirection="self",this}dispatchTo(t,i,e){return this.dispatch(i,e),this.dispatchDirection="to",this.dispatchToComponent=t,this}emit(t,i){return this.dispatch(t,i),this}emitSelf(t,i){return this.dispatchSelf(t,i),this}emitTo(t,i,e){return this.dispatchTo(t,i,e),this}dispatchDirection(t){return this.dispatchDirection=t,this}dispatchToComponent(t){return this.dispatchToComponent=t,this}event(t){return this.event=t,this}eventData(t){return this.eventData=t,this}extraAttributes(t){return this.extraAttributes=t,this}icon(t){return this.icon=t,this}iconPosition(t){return this.iconPosition=t,this}outlined(t=!0){return this.isOutlined=t,this}disabled(t=!0){return this.isDisabled=t,this}label(t){return this.label=t,this}close(t=!0){return this.shouldClose=t,this}openUrlInNewTab(t=!0){return this.shouldOpenUrlInNewTab=t,this}size(t){return this.size=t,this}url(t){return this.url=t,this}view(t){return this.view=t,this}button(){return this.view("filament::components.button.index"),this}grouped(){return this.view("filament::components.dropdown.list.item"),this}iconButton(){return this.view("filament::components.icon-button"),this}link(){return this.view("filament::components.link"),this}},u=class{constructor(t){return this.actions(t),this}actions(t){return this.actions=t.map(i=>i.grouped()),this}color(t){return this.color=t,this}icon(t){return this.icon=t,this}iconPosition(t){return this.iconPosition=t,this}label(t){return this.label=t,this}tooltip(t){return this.tooltip=t,this}};window.FilamentNotificationAction=a;window.FilamentNotificationActionGroup=u;window.FilamentNotification=h;document.addEventListener("alpine:init",()=>{window.Alpine.plugin(d)});})();
@@ -0,0 +1 @@
var i=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let e=this.$el.parentElement;e&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(e),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let e=this.$el.parentElement;if(!e)return;let t=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=e.offsetWidth+parseInt(t.marginInlineStart,10)*-1+parseInt(t.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});export{i as default};
@@ -0,0 +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};
@@ -0,0 +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};
+1
View File
@@ -0,0 +1 @@
(()=>{var o=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let i=this.$el.parentElement;i&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(i),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let i=this.$el.parentElement;if(!i)return;let e=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=i.offsetWidth+parseInt(e.marginInlineStart,10)*-1+parseInt(e.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});var a=function(i,e,n){let t=i;if(e.startsWith("/")&&(n=!0,e=e.slice(1)),n)return e;for(;e.startsWith("../");)t=t.includes(".")?t.slice(0,t.lastIndexOf(".")):null,e=e.slice(3);return["",null,void 0].includes(t)?e:["",null,void 0].includes(e)?t:`${t}.${e}`},d=i=>{let e=Alpine.findClosest(i,n=>n.__livewire);if(!e)throw"Could not find Livewire component in DOM tree.";return e.__livewire};document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:i})=>({handleFormValidationError(e){e.detail.livewireId===i&&this.$nextTick(()=>{let n=this.$el.querySelector("[data-validation-error]");if(!n)return;let t=n;for(;t;)t.dispatchEvent(new CustomEvent("expand")),t=t.parentNode;setTimeout(()=>n.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})},isStateChanged(e,n){if(e===void 0)return!1;try{return JSON.stringify(e)!==JSON.stringify(n)}catch{return e!==n}}})),window.Alpine.data("filamentSchemaComponent",({path:i,containerPath:e,$wire:n})=>({$statePath:i,$get:(t,r)=>n.$get(a(e,t,r)),$set:(t,r,s,l=!1)=>n.$set(a(e,t,s),r,l),get $state(){return n.$get(i)}})),window.Alpine.data("filamentActionsSchemaComponent",o),Livewire.interceptMessage(({message:i,onSuccess:e})=>{e(({payload:n})=>{n.effects?.dispatches?.forEach(t=>{if(!t.params?.awaitSchemaComponent)return;let r=Array.from(i.component.el.querySelectorAll(`[wire\\:partial="schema-component::${t.params.awaitSchemaComponent}"]`)).filter(s=>d(s)===i.component);if(r.length!==1){if(r.length>1)throw`Multiple schema components found with key [${t.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${component.id}-${t.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(t.name,{detail:t.params}))},{once:!0})}})})})});})();
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
function a({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let i=this.getServerState();i===void 0||Alpine.raw(this.state)===i||(this.state=i)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(r,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{a as default};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
function a({name:i,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let r=this.getServerState();r===void 0||this.getNormalizedState()===r||(this.state=r)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(i,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e},destroy(){this.unsubscribeLivewireHook?.()}}}export{a as default};
@@ -0,0 +1 @@
function a({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let i=this.getServerState();i===void 0||Alpine.raw(this.state)===i||(this.state=i)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(r,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{a as default};
File diff suppressed because one or more lines are too long
+64 -1
View File
@@ -1,6 +1,7 @@
@import url("https://fonts.bunny.net/css?family=Inter:400,500,600&display=swap");
@import "tailwindcss";
@import 'tailwindcss';
@plugin "@tailwindcss/typography";
@theme {
@@ -89,10 +90,72 @@
--noise: 0;
}
/* Required by all components */
@import '../../vendor/filament/support/resources/css/index.css';
/* Required by actions and tables */
@import '../../vendor/filament/actions/resources/css/index.css';
/* Required by actions, forms and tables */
@import '../../vendor/filament/forms/resources/css/index.css';
/* Required by actions, infolists, forms, schemas and tables */
@import '../../vendor/filament/schemas/resources/css/index.css';
/* Required by tables */
@import '../../vendor/filament/tables/resources/css/index.css';
@source "../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php";
@source "../../storage/framework/views/*.php";
@source "../**/*.blade.php";
/* table overrides */
.fi-ta-ctn {
@apply bg-transparent rounded-none border-none ring-0 shadow-none;
& table.fi-ta-table thead tr {
@apply bg-transparent;
}
& table.fi-ta-table thead tr th.fi-ta-header-cell {
color: color-mix(in oklab,var(--color-base-content)60%,transparent) !important;
text-transform: uppercase;
letter-spacing: .05rem;
font-size: .725rem;
font-weight: normal;
& .fi-icon {
color: color-mix(in oklab,var(--color-base-content)60%,transparent) !important;
}
}
& table.fi-ta-table .fi-clickable:hover {
background-color: color-mix(in oklab,var(--color-base-200)50%,transparent) !important;
}
& [data-theme=dark] table.fi-ta-table .fi-clickable:hover {
background-color: var(--color-base-100);
}
& ol.fi-pagination-items {
& .fi-pagination-item-label {
color: var(--color-base-content);
}
}
& td.fi-ta-cell div.fi-ta-text {
color: var(--color-base-content);
}
& table.fi-ta-table.fi-ta-table-stacked-on-mobile td {
padding-block: 0;
}
& .fi-ta-header-toolbar {
border-bottom-width: 0;
}
}
/* Tool tip for apex charts */
[data-theme=dark] .apexcharts-tooltip-title {
background: #292933 !important;
-2
View File
@@ -1,8 +1,6 @@
import ApexCharts from 'apexcharts'
window.ApexCharts = ApexCharts;
import '../../vendor/rappasoft/laravel-livewire-tables/resources/imports/laravel-livewire-tables.js';
import axios from 'axios';
window.axios = axios;
@@ -44,7 +44,7 @@ new class extends Component
}
}; ?>
<x-form wire:submit="updateUserInformation" class="">
<x-ui.form wire:submit="updateUserInformation" class="">
<div class="mt-2">
@@ -67,4 +67,4 @@ new class extends Component
{{ __('Get Started') }}
</x-ui.button>
</div>
</x-form>
</x-ui.form>
@@ -23,6 +23,7 @@
@endif
<x-ui.toast />
@filamentScripts
@livewireScripts
</body>
</html>
@@ -7,4 +7,5 @@
@vite(['resources/css/app.css', 'resources/js/app.js'])
@filamentStyles
@livewireStyles
@@ -1,7 +1,16 @@
<?php
use App\Models\AiChat;
use App\Ai\Agents\ChatWithHoldingAgent;
use App\Ai\Agents\ChatWithPortfolioAgent;
use App\Ai\Agents\ChatWithSuggestedPromptsAgent;
use App\Models\ChatWithConversation;
use App\Models\Holding;
use App\Models\Portfolio;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Streaming\Events\TextDelta;
use Livewire\Attributes\Async;
use Livewire\Volt\Component;
new class extends Component
@@ -9,8 +18,6 @@ new class extends Component
// props
public Model $chatable;
public string $system_prompt = 'You are an investment portfolio assistant providing advice to an investor. Use the following information to provide relevant recommendations. Use the words \'likely\' or \'may\' instead of concrete statements (except for obvious statements of fact or common sense). Use github style markdown for any formatting.';
public array $suggested_prompts = [];
public array $messages = [];
@@ -21,19 +28,44 @@ new class extends Component
public bool $streaming = false;
public ?string $agent_conversation_id = null;
// methods
public function mount()
public function mount(): void
{
$this->messages = $this->chatable->chats()->orderByRaw('created_at, id')->limit(25)->get(['role', 'content'])->toArray();
$chatWith = ChatWithConversation::firstOrCreate(
[
'chatable_type' => $this->chatable::class,
'chatable_id' => $this->chatable->id,
'user_id' => auth()->id(),
],
['title' => 'Chat with investments']
);
$this->agent_conversation_id = $chatWith->id;
$this->messages = $chatWith->messages()
->orderBy('id', 'desc')
->limit(20)
->get(['role', 'content', 'created_at'])
->map(fn ($m) => ['role' => $m->role, 'content' => $m->content, 'created_at' => $m->created_at])
->reverse()
->values()
->toArray();
}
public function startCompletion($suggestedPrompt = null)
public function startCompletion(?string $suggestedPrompt = null): void
{
if ($this->streaming) {
return;
}
// prevent spam
if ($this->isRateLimited() || $this->streaming) {
if ($this->isRateLimited()) {
array_push($this->messages, [
'role' => 'assistant',
'content' => __('Hang on! You\'re doing that too much.'),
'created_at' => now(),
]);
$this->js('scrollChatWindow(250)');
@@ -42,19 +74,19 @@ new class extends Component
if ($suggestedPrompt) {
$this->prompt = $suggestedPrompt;
$this->suggested_prompts = [];
}
if (empty(trim($this->prompt))) {
if (empty(trim($this->prompt ?? ''))) {
$this->resetPrompt();
array_push($this->messages, ['role' => 'assistant', 'content' => __('Feel free to ask me a question!')]);
array_push($this->messages, ['role' => 'assistant', 'content' => __('Feel free to ask me a question!'), 'created_at' => now()]);
$this->js('scrollChatWindow(250)');
return;
}
$this->chatable->chats()->save(new AiChat(['role' => 'user', 'content' => $this->prompt]));
array_push($this->messages, ['role' => 'user', 'content' => $this->prompt]);
array_push($this->messages, ['role' => 'user', 'content' => $this->prompt, 'created_at' => now()]);
$this->js('scrollChatWindow(250)');
$this->resetPrompt();
@@ -65,23 +97,13 @@ new class extends Component
public function generateCompletion(): void
{
$userPrompt = end($this->messages)['content'] ?? '';
try {
$client = $this->createOpenAiClient();
$stream = $client->chat()->createStreamed([
'model' => config('openai.model'),
'messages' => [
['role' => 'system', 'content' => "Today's date is "
.now()->toDateString()
.".\n\n".$this->system_prompt],
...array_slice($this->messages, -10),
],
]);
} catch (\Exception $e) {
$this->chatable->chats()->save(new AiChat(['role' => 'assistant', 'content' => $e->getMessage()]));
array_push($this->messages, ['role' => 'assistant', 'content' => $e->getMessage()]);
$agent = $this->makeAgent()->continue($this->agent_conversation_id, auth()->user());
$stream = $agent->stream($userPrompt);
} catch (Exception $e) {
array_push($this->messages, ['role' => 'assistant', 'content' => $e->getMessage(), 'created_at' => now()]);
$this->resetPrompt();
return;
@@ -89,91 +111,31 @@ new class extends Component
$this->stream(to: 'answer', content: '', replace: true);
foreach ($stream as $response) {
if (! empty($response->choices[0]->delta->content)) {
$this->stream(to: 'answer', content: $response->choices[0]->delta->content, replace: false);
$this->answer .= $response->choices[0]->delta->content;
foreach ($stream as $event) {
if ($event instanceof TextDelta) {
$this->stream(to: 'answer', content: $event->delta, replace: false);
$this->answer .= $event->delta;
}
$this->js('scrollChatWindow()');
}
$this->chatable->chats()->save(new AiChat(['role' => 'assistant', 'content' => $this->answer]));
array_push($this->messages, ['role' => 'assistant', 'content' => $this->answer]);
array_push($this->messages, ['role' => 'assistant', 'content' => $this->answer, 'created_at' => now()]);
$this->resetPrompt();
$this->js('$wire.generateSuggestedPrompts()');
}
#[Async]
public function generateSuggestedPrompts(): void
{
try {
$client = $this->createOpenAiClient();
$suggested_prompts = $client->chat()->create([
'model' => config('openai.model'),
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'suggested_prompts_schema',
'strict' => true,
'schema' => [
'type' => 'object',
'properties' => [
'suggested_prompts' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'text' => [
'type' => 'string',
'description' => 'The suggested prompt question (no more than 5 words)',
],
'value' => [
'type' => 'string',
'description' => 'The detailed version of the question',
],
],
'required' => ['text', 'value'],
'additionalProperties' => false,
],
],
],
'required' => ['suggested_prompts'],
'additionalProperties' => false,
],
],
],
'messages' => [
['role' => 'system', 'content' => '
Your role is to assist investors in asking thoughtful questions of their investment advisors.
When you help investors ask good questions, you should ensure the you questions you recommend
are based on the provided context. Be sure to keep the questions short!
The questions you recommend might be based on natural follow up from the given context, requests
to further refine a previous response, clarify undefined terms, common decision frameworks,
possible risks or benefits, or commonly understood investing concepts that may require additional
explanation.
Your response should only include valid JSON.
'],
['role' => 'user', 'content' => "
Generate between 1 and 5 (no more than 5) follow up questions a savvy investor might ask their
advisor based on the following conversation:
\n\n
".json_encode(array_slice($this->messages, -4)),
],
],
]);
$this->suggested_prompts = json_decode($suggested_prompts->choices[0]->message->content, true)['suggested_prompts'];
} catch (\Exception $e) {
$response = ChatWithSuggestedPromptsAgent::make(messages: array_slice($this->messages, -3))->prompt('');
$this->suggested_prompts = $response->toArray()['suggested_prompts'] ?? [];
} catch (Exception $e) {
$this->suggested_prompts = [];
$this->error($e->getMessage());
return;
}
}
@@ -189,7 +151,6 @@ new class extends Component
$rateLimitKey = auth()->id().'/'.$this->chatable->id;
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
return true;
}
@@ -198,41 +159,34 @@ new class extends Component
return false;
}
private function createOpenAiClient()
private function makeAgent(): Agent
{
$apiKey = config('openai.api_key');
$organization = config('openai.organization');
$baseUri = config('openai.base_uri');
return OpenAI::factory()
->withApiKey($apiKey)
->withOrganization($organization)
->withHttpHeader('OpenAI-Beta', 'assistants=v2')
->withHttpClient(new \GuzzleHttp\Client(['timeout' => config('openai.request_timeout', 30)]))
->withBaseUri($baseUri)
->make();
return match (true) {
$this->chatable instanceof Portfolio => new ChatWithPortfolioAgent($this->chatable),
$this->chatable instanceof Holding => new ChatWithHoldingAgent($this->chatable),
};
}
}; ?>
<div
x-data="{
open: false,
async scrollChatWindow(delay = 0) {
await new Promise(resolve => setTimeout(resolve, delay));
this.$refs.chatWindow.scrollBy({
top: this.$refs.chatWindow.scrollHeight,
behavior: 'smooth'
});
}
}"
<div
x-data="{
open: false,
async scrollChatWindow(delay = 0) {
await new Promise(resolve => setTimeout(resolve, delay));
this.$refs.chatWindow.scrollBy({
top: this.$refs.chatWindow.scrollHeight,
behavior: 'smooth'
});
}
}"
class="fixed z-50 bottom-8 right-8"
>
{{-- toggle button --}}
<x-ui.button
<x-ui.button
x-show="!open"
@click="$dispatch('toggle-ai-chat')"
@keyup.escape.window="open = false"
class="flex btn btn-circle md:btn-lg btn-primary"
class="flex btn btn-circle md:btn-lg btn-primary"
>
<x-slot:label>
<x-ui.icon name="o-sparkles" class="w-6 h-6 md:w-8 md:h-8"></x-ui.icon>
@@ -240,10 +194,10 @@ new class extends Component
</x-ui.button>
{{-- popup --}}
<div
<div
x-on:toggle-ai-chat.window="open = !open"
x-show="open"
x-trap="open"
x-trap="open"
x-bind:inert="!open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform translate-y-full"
@@ -252,22 +206,22 @@ new class extends Component
x-transition:leave-start="opacity-100 transform translate-y-0"
x-transition:leave-end="opacity-0 transform translate-y-full"
x-cloak
key="ai-chat"
key="ai-chat"
class="fixed bg-base-300 shadow-2xl rounded-none md:rounded-lg
inset-0 h-screen w-full md:inset-auto md:right-6
inset-0 h-screen w-full md:inset-auto md:right-6
md:bottom-6 md:w-[32rem] md:h-[46rem]"
>
<div
class="absolute inset-0 flex flex-col overflow-hidden p-4"
<div
class="absolute inset-0 flex flex-col overflow-hidden p-4"
x-intersect="scrollChatWindow()"
>
<div class="flex grow-0 justify-between items-center pb-4 ">
<h2 class="text-lg text-bold select-none">{{ __('AI Chat') }}</h2>
<x-ui.button
icon="o-x-mark"
class="absolute top-5 right-4 btn-ghost btn-circle btn-sm"
<x-ui.button
icon="o-x-mark"
class="absolute top-5 right-4 btn-ghost btn-circle btn-sm"
title="{{ __('Close') }}"
@click="open = false"
@click="open = false"
/>
</div>
@@ -290,47 +244,50 @@ new class extends Component
</span>
<p class="leading-relaxed w-full">
<span class="block font-bold">AI</span> {{ __('Hi, how can I help?') }}
</p>
</div>
@foreach($messages as $message)
@foreach($messages as $message)
<div class="flex gap-3 mb-5 flex-1">
@if ($message['role'] == 'user')
<div class="flex gap-3 mb-5 flex-1">
<span class="relative flex shrink-0 overflow-hidden rounded-full w-10 h-10">
<x-ui.avatar :image="auth()->user()->profile_photo_url" class="!w-10" />
</span>
<p class="leading-relaxed">
<span class="block font-bold ">{{ __('You') }} </span> {{ $message['content'] }}
</p>
</div>
<span class="relative flex shrink-0 overflow-hidden rounded-full w-10 h-10">
<x-ui.avatar :image="auth()->user()->profile_photo_url" class="!w-10" />
</span>
<p class="leading-relaxed">
<span class="block font-bold" title="{{ $message['created_at'] }}">{{ __('You') }} </span> {{ $message['content'] }}
</p>
@else
<div class="flex gap-3 mb-5 flex-1">
<span class="
flex
rounded-full
w-10 h-10
border border-gray-600
dark:border-gray-400
text-gray-600
dark:text-gray-400
bg-slate-200
dark:bg-slate-800
">
<x-ui.icon name="o-sparkles" class="h-auto p-1 w-10" />
</span>
<div class="leading-relaxed" >
<span class="block font-bold ">AI </span> {!! Str::markdown($message['content']) !!}
</div>
<span class="
flex
rounded-full
w-10 h-10
border border-gray-600
dark:border-gray-400
text-gray-600
dark:text-gray-400
bg-slate-200
dark:bg-slate-800
">
<x-ui.icon name="o-sparkles" class="h-auto p-1 w-10" />
</span>
<div class="leading-relaxed" >
<span class="block font-bold" title="{{ $message['created_at'] }}">AI </span> {!! Str::markdown($message['content']) !!}
</div>
@endif
</div>
@endforeach
@if($streaming)
<div class="flex gap-3 mb-10 flex-1">
<span class="
@@ -343,7 +300,7 @@ new class extends Component
dark:text-gray-400
bg-slate-200
dark:bg-slate-800
">
">
<x-ui.icon name="o-sparkles" class="h-auto p-1 w-10" />
</span>
<p class="leading-relaxed" >
@@ -352,24 +309,26 @@ new class extends Component
</div>
@endif
</div>
{{-- prompt input --}}
<div class="mt-3 grow-0">
<form submit="startCompletion">
<form wire:submit.prevent>
<div class="">
@foreach($suggested_prompts as $prompt)
<x-ui.button
class="btn-xs btn-primary btn-outline mr-1 mb-2"
wire:click="startCompletion('{{ addslashes($prompt['value']) }}')"
<x-ui.button
class="btn-xs btn-primary btn-outline mr-1 mb-2"
wire:click="startCompletion('{{ addslashes($prompt['value']) }}')"
>{{ $prompt['text'] }}</x-ui.button>
@endforeach
</div>
<div class="flex justify-between align-bottom space-x-2 mt-1">
<div class="w-full" >
<div class="w-full">
<x-ui.textarea
wire:model="prompt"
class="h-18 resize-none bg-base-200"
@@ -377,18 +336,19 @@ new class extends Component
wire:keydown.enter.prevent="startCompletion"
autofocus
@toggle-ai-chat.window="setTimeout(() => $el.focus(), 250)"
x-trap="true"
></x-ui.textarea>
{{-- --}}
</div>
<x-ui.button
spinner="generateCompletion"
wire:click="startCompletion"
spinner="startCompletion, generateCompletion"
wire:click.prevent="startCompletion"
class="btn btn-ghost h-32"
icon="o-paper-airplane"
></x-ui.button>
</div>
<div class="w-full mt-2">
<p class="text-xs text-secondary leading-tight select-none">{{ __('Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.') }} </p>
</div>
@@ -396,4 +356,4 @@ new class extends Component
</div>
</div>
</div>
</div>
</div>
@@ -31,6 +31,6 @@
</div>
@if($dismissible)
<x-button icon="o-x-mark" @click="show = false" class="btn-xs btn-circle btn-ghost static self-start end-0" />
<x-ui.button icon="o-x-mark" @click="show = false" class="btn-xs btn-circle btn-ghost static self-start end-0" />
@endif
</div>
@@ -99,6 +99,7 @@
this.data.tooltip = {
enabled: true,
shared: false,
y: {
formatter: (value, { series, seriesIndex, dataPointIndex, w }) => {
const firstDataPoint = this.data.series[seriesIndex].data[0][1]
@@ -25,7 +25,6 @@
<button
@endif
{{ $attributes->whereDoesntStartWith('class')->merge(['type' => $type]) }}
type="button"
{{ $attributes->class(['btn', "!inline-flex lg:tooltip $tooltipPosition" => $tooltip]) }}
@if($link && $external)
@@ -21,97 +21,11 @@
input[type="date"]::-webkit-calendar-picker-indicator {
color: transparent;
background: transparent;
display: none;
}
</style>
<div
x-cloak
x-data="{
datePickerOpen: false,
datePickerValue: $wire.entangle(@js($modelName)),
datePickerMonth: '',
datePickerYear: '',
datePickerDay: '',
datePickerDaysInMonth: [],
datePickerBlankDaysInMonth: [],
datePickerMonthNames: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
datePickerDays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
datePickerDayClicked(day) {
let selectedDate = new Date(this.datePickerYear, this.datePickerMonth, day);
this.datePickerDay = day;
this.datePickerValue = this.dateToValue(selectedDate);
this.datePickerIsSelectedDate(day);
this.datePickerOpen = false;
},
datePickerPreviousMonth(){
if (this.datePickerMonth == 0) {
this.datePickerYear--;
this.datePickerMonth = 12;
}
this.datePickerMonth--;
this.datePickerCalculateDays();
},
datePickerNextMonth(){
if (this.datePickerMonth == 11) {
this.datePickerMonth = 0;
this.datePickerYear++;
} else {
this.datePickerMonth++;
}
this.datePickerCalculateDays();
},
datePickerIsSelectedDate(day) {
const d = new Date(this.datePickerYear, this.datePickerMonth, day);
return this.datePickerValue === this.dateToValue(d) ? true : false;
},
datePickerIsToday(day) {
const today = new Date();
const d = new Date(this.datePickerYear, this.datePickerMonth, day);
return today.toDateString() === d.toDateString() ? true : false;
},
datePickerCalculateDays() {
let daysInMonth = new Date(this.datePickerYear, this.datePickerMonth + 1, 0).getDate();
// find where to start calendar day of week
let dayOfWeek = new Date(this.datePickerYear, this.datePickerMonth).getDay();
let blankdaysArray = [];
for (var i = 1; i <= dayOfWeek; i++) {
blankdaysArray.push(i);
}
let daysArray = [];
for (var i = 1; i <= daysInMonth; i++) {
daysArray.push(i);
}
this.datePickerBlankDaysInMonth = blankdaysArray;
this.datePickerDaysInMonth = daysArray;
},
dateToValue(d) {
d = this.parseDate(d)
let formattedDate = ('0' + d.getDate()).slice(-2);
let formattedMonthInNumber = ('0' + (parseInt(d.getMonth()) + 1)).slice(-2);
let formattedYear = d.getFullYear();
return `${formattedYear}-${formattedMonthInNumber}-${formattedDate}`;
},
parseDate(d) {
date = new Date();
let userTimezoneOffset = date.getTimezoneOffset() * 60000;
return new Date(Date.parse(d) + userTimezoneOffset);
}
}"
x-init="
currentDate = new Date();
if (datePickerValue) {
currentDate = parseDate(datePickerValue)
}
datePickerMonth = currentDate.getMonth();
datePickerYear = currentDate.getFullYear();
datePickerDay = currentDate.getDay();
datePickerValue = currentDate.toISOString().slice(0, 10);
datePickerCalculateDays();
"
>
<div x-data>
{{-- STANDARD LABEL --}}
@if($label)
<label for="{{ $id }}" class="pt-0 label label-text font-semibold">
@@ -126,96 +40,15 @@
@endif
<div class="flex-1 relative">
{{-- DESKTOP --}}
<div
x-ref="desktopDatePickerInput"
x-html="parseDate(datePickerValue).toLocaleDateString()"
x-on:keydown.escape="datePickerOpen=false"
@click="datePickerOpen=true"
{{ $attributes->class([
"hidden md:block py-2 input px-4 input-primary w-full peer appearance-none",
'ps-10' => ($icon),
'border border-dashed' => $attributes->has('readonly') && $attributes->get('readonly') == true,
'input-error' => $errors->has($errorFieldName)
]) }}
></div>
<div
x-show="datePickerOpen"
x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-x-2"
x-transition:enter-end="translate-x-0"
@click.away="datePickerOpen = false"
class="
p-4
mt-12
top-0
left-0
max-w-lg
w-[17rem]
absolute
z-100
bg-base-100
dark:bg-base-300
rounded-box
shadow-md
select-none
"
>
<div class="flex justify-between items-center mb-2">
<div>
<span x-text="datePickerMonthNames[datePickerMonth]" class="text-lg font-bold"></span>
<span x-text="datePickerYear" class="ml-1 text-lg font-normal text-gray-600"></span>
</div>
<div>
<button @click="datePickerPreviousMonth()" type="button" class="inline-flex p-1 rounded-full transition duration-100 ease-in-out cursor-pointer focus:outline-none focus:shadow-outline hover:bg-accent/50">
<x-ui.icon name="o-chevron-left" />
</button>
<button @click="datePickerNextMonth()" type="button" class="inline-flex p-1 rounded-full transition duration-100 ease-in-out cursor-pointer focus:outline-none focus:shadow-outline hover:bg-accent/50">
<x-ui.icon name="o-chevron-right" />
</button>
</div>
</div>
<div class="grid grid-cols-7 mb-3">
<template x-for="(day, index) in datePickerDays" :key="index">
<div class="px-0.5">
<div x-text="day" class="text-xs font-medium text-center"></div>
</div>
</template>
</div>
<div class="grid grid-cols-7">
<template x-for="blankDay in datePickerBlankDaysInMonth">
<div class="p-1 text-sm text-center border border-transparent"></div>
</template>
<template x-for="(day, dayIndex) in datePickerDaysInMonth" :key="dayIndex">
<div class="px-0.5 mb-1 aspect-square">
<div
x-text="day"
@click="datePickerDayClicked(day)"
:class="{
'border border-accent/50': datePickerIsToday(day) == true,
'hover:bg-neutral-800/70': datePickerIsToday(day) == false && datePickerIsSelectedDate(day) == false,
'text-primary-content bg-primary hover:bg-primary/50': datePickerIsSelectedDate(day) == true
}"
class="flex justify-center items-center w-7 h-7 text-sm leading-none text-center rounded-full cursor-pointer"
></div>
</div>
</template>
</div>
</div>
{{-- MOBILE/NATIVE --}}
<input
<input
type="date"
x-model="datePickerValue"
placeholder="Select date"
id="{{ $id }}"
x-ref="dateInput"
onfocus="this.showPicker?.()"
x-ref="mobileDatePickerInput"
{{ $attributes->class([
"block md:hidden input input-primary w-full peer appearance-none",
"block input input-primary w-full peer appearance-none",
'ps-10' => ($icon),
'border border-dashed' => $attributes->has('readonly') && $attributes->get('readonly') == true,
'input-error' => $errors->has($errorFieldName)
@@ -223,23 +56,11 @@
/>
{{-- ICON --}}
<div @click="
if ($refs.mobileDatePickerInput?.checkVisibility()) {
$refs.mobileDatePickerInput?.showPicker()
return;
}
if(datePickerOpen) {
$refs.desktopDatePickerInput.focus();
return;
}
datePickerOpen=!datePickerOpen;
"
<div @click="$refs.dateInput.showPicker?.()"
class="z-60 absolute top-1/2 -translate-y-1/2 end-0 p-3 cursor-pointer text-neutral-400 hover:text-neutral-500"
>
<x-ui.icon name="o-calendar" />
</div>
</div>
{{-- ERROR --}}
+3 -29
View File
@@ -174,15 +174,12 @@
</x-ui.card>
@if(config('services.ai_chat_enabled'))
{{-- // todo: add to system prompt:
// Additionally, here is some recent news about {$this->holding->symbol}:
// And their latest SEC filings: --}}
@livewire('ui.ai-chat-window', [
'chatable' => $holding,
'suggested_prompts' => [
[
'text' => 'What are the key risks?',
'value' => 'What are the key risks for the company?'
'value' => 'What are the key risks for the holding?'
],
[
'text' => 'Should I invest more?',
@@ -190,40 +187,17 @@
],
[
'text' => 'Should I sell?',
'value' => 'When is a good time for me to sell?'
'value' => 'When would be a good time for me to sell?'
],
[
'text' => 'What are the key strengths?',
'value' => 'What are the key strengths for this company?'
'value' => 'Can you tell me the key strengths for this holding?'
],
[
'text' => 'Is this a successful position?',
'value' => 'Is this a successful holding in my portfolio?'
]
],
'system_prompt' => "
Most recent training data: " . now()->toDateString() . ".
You are an investment portfolio assistant providing advice to an investor. Use the following information to provide relevant recommendations. Use the words 'likely' or 'may' instead of concrete statements (except for obvious statements of fact or common sense). . Do not apologize. Be polite, but minimize gratuitous niceties. When referencing numbers, always round to the nearest 100th decimal place.
The investor owns ". ($holding->quantity > 0 ? 'a total of '.$holding->quantity : 'ZERO') ." shares of {$holding->market_data->name} (ticker: {$holding->symbol}) with an average cost basis of {$holding->average_cost_basis}. Here are the relevant transactions - sales and purchases of {$holding->symbol}:
{$formattedTransactions}
This investor has earned $ {$holding->dividends_earned} in dividends so far and earned {$holding->realized_gains_dollars} in realized gains (sales) from {$holding->symbol} in this portfolio.
The current market price for {$holding->symbol} is {$holding->market_data->market_value}. Additionally, here's other critical fundamentals for {$holding->market_data->name} that might help:
* Market cap: {$holding->market_data->market_cap}
* Forward PE: {$holding->market_data->forward_pe}
* Trailing PE: {$holding->market_data->trailing_pe}
* Book value: {$holding->market_data->book_value}
* 52 week low: {$holding->market_data->fifty_two_week_low}
* 52 week high: {$holding->market_data->fifty_two_week_high}
* Dividend yield: {$holding->market_data->dividend_yield}
Based on this current market data, quantity owned, and average cost basis, you should determine if the {$holding->symbol} holding is making or losing money.
Below is the question from the investor. Considering these facts, provide a concise response to the following question (give a direct response). Limit your response to no more than 75 words and consider using a common decision framework. Use github style markdown for any formatting:"
])
@endif
@@ -75,26 +75,29 @@ new #[Lazy] class extends Component
foreach ($dailyChange as $data) {
$date = $data->date;
$marketGainData[] = [$date, round($data->total_market_gain, 2)];
$marketValueData[] = [$date, round($data->total_market_value, 2)];
$costBasisData[] = [$date, round($data->total_cost_basis, 2)];
$marketGainData[] = [$date, round($data->total_market_gain, 2)];
// $dividendSeries[] = [$date, round($data->total_dividends_earned, 2)];
// $realizedGainSeries[] = [$date, round($data->realized_gains, 2)];
}
return [
'series' => [
[
'name' => __('Market Gain'),
'data' => $marketGainData,
],
[
'name' => __('Market Value'),
'data' => $marketValueData,
'hidden' => true,
],
[
'name' => __('Cost Basis'),
'data' => $costBasisData,
],
[
'name' => __('Market Gain'),
'data' => $marketGainData,
'hidden' => true,
],
// [
@@ -154,7 +154,7 @@ new class extends Component
<x-slot:actions>
@if (auth()->user()->id != $user->id)
<x-ui.select
class="select select-ghost border-none focus:outline-none focus:ring-0"
class="cursor-pointer select-ghost border-none focus:outline-none focus:ring-0"
:options="[['id' => 0, 'name' => __('Read only')], ['id' => 1, 'name' => __('Full access')]]"
wire:model.live.number="permissions.{{ $user->id }}.full_access"
/>
+5 -13
View File
@@ -99,7 +99,7 @@
@else
@livewire('datatables.holdings-table', [
@livewire('tables.holdings-table', [
'portfolio' => $portfolio
])
@@ -162,20 +162,12 @@
[
'text' => 'Should I diversify more?',
'value' => 'Is my portfolio diverse enough?',
],
[
'text' => 'Analyze my portfolio?',
'value' => 'Can you analyze my portfolio for risks or opportunities?',
]
],
'system_prompt' => "
Most recent training data: " . now()->toDateString() . ".
You are an investment portfolio assistant providing advice to an investor. Use the following information to provide relevant recommendations. Use the words 'likely' or 'may' in lieu of concrete statements (except for obvious statements of fact or common sense). Do not apologize. Be polite, but minimize gratuitous niceties. When referencing numbers, always round to the nearest 100th decimal place.
The investor has the following holdings in this portfolio:
{$formattedHoldings}
Based on the current market data, quantity owned, and average cost basis, you can determine the performance of any holding.
Below is the question from the investor. Considering these facts, provide a concise response to the following question (give a direct response). Limit your response to no more than 75 words and consider using a common decision framework. Use github style markdown for any formatting:"
])
@endif
@@ -68,8 +68,8 @@ new class extends Component
<div class="col-span-6 sm:col-span-4">
<x-ui.select
label="{{ __('Locale') }}"
class="select block mt-1 w-full"
:label="__('Locale')"
class=""
:options="config('app.available_locales')"
option-value="locale"
option-label="label"
@@ -83,8 +83,8 @@ new class extends Component
<div class="col-span-6 sm:col-span-4">
<x-ui.select
label="{{ __('Display Currency') }}"
class="select block mt-1 w-full"
:label="__('Display Currency')"
class=""
:options="$currencies"
option-value="currency"
option-label="label"
+3 -1
View File
@@ -22,6 +22,8 @@
</div>
</x-ui.toolbar>
@livewire('datatables.transactions-table')
@livewire('tables.transactions-table')
</div>
</x-layouts.app>
+181
View File
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace Tests;
use App\Ai\Agents\ChatWithPortfolioAgent;
use App\Ai\Agents\ChatWithSuggestedPromptsAgent;
use App\Models\ChatWithConversation;
use App\Models\Portfolio;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Livewire\Volt\Volt;
class ChatWithTest extends TestCase
{
use RefreshDatabase;
public function test_mount_creates_chat_with_conversation_record(): void
{
ChatWithPortfolioAgent::fake(['Test response']);
ChatWithSuggestedPromptsAgent::fake([['suggested_prompts' => []]]);
$this->actingAs($user = User::factory()->create());
$portfolio = Portfolio::factory()->create();
$this->assertDatabaseCount('agent_conversations', 0);
Volt::test('ui.ai-chat-window', ['chatable' => $portfolio]);
$this->assertDatabaseHas('agent_conversations', [
'chatable_type' => Portfolio::class,
'chatable_id' => $portfolio->id,
'user_id' => $user->id,
]);
}
public function test_mount_reuses_existing_chat_with_conversation(): void
{
ChatWithPortfolioAgent::fake(['Test response']);
ChatWithSuggestedPromptsAgent::fake([['suggested_prompts' => []]]);
$this->actingAs(User::factory()->create());
$portfolio = Portfolio::factory()->create();
Volt::test('ui.ai-chat-window', ['chatable' => $portfolio]);
Volt::test('ui.ai-chat-window', ['chatable' => $portfolio]);
$this->assertDatabaseCount('agent_conversations', 1);
}
public function test_mount_loads_existing_messages(): void
{
ChatWithPortfolioAgent::fake(['Test response']);
ChatWithSuggestedPromptsAgent::fake([['suggested_prompts' => []]]);
$this->actingAs($user = User::factory()->create());
$portfolio = Portfolio::factory()->create();
$conversation = ChatWithConversation::create([
'chatable_type' => Portfolio::class,
'chatable_id' => $portfolio->id,
'user_id' => $user->id,
'title' => 'Chat with investments',
]);
DB::table('agent_conversation_messages')->insert([
'id' => (string) Str::uuid7(),
'conversation_id' => $conversation->id,
'user_id' => $user->id,
'agent' => ChatWithPortfolioAgent::class,
'role' => 'user',
'content' => 'Previous question',
'attachments' => '[]',
'tool_calls' => '[]',
'tool_results' => '[]',
'usage' => '[]',
'meta' => '[]',
'created_at' => now(),
'updated_at' => now(),
]);
$component = Volt::test('ui.ai-chat-window', ['chatable' => $portfolio]);
$messages = $component->get('messages');
$this->assertCount(1, $messages);
$this->assertEquals('user', $messages[0]['role']);
$this->assertEquals('Previous question', $messages[0]['content']);
}
public function test_start_completion_adds_user_message_and_sets_streaming(): void
{
ChatWithPortfolioAgent::fake(['The portfolio looks great!']);
ChatWithSuggestedPromptsAgent::fake([['suggested_prompts' => []]]);
$this->actingAs(User::factory()->create());
$portfolio = Portfolio::factory()->create();
Volt::test('ui.ai-chat-window', ['chatable' => $portfolio])
->set('prompt', 'How is my portfolio doing?')
->call('startCompletion')
->assertSet('streaming', true)
->assertSee('How is my portfolio doing?');
}
public function test_generate_completion_calls_agent_and_stores_response(): void
{
ChatWithPortfolioAgent::fake(['The portfolio looks great!']);
ChatWithSuggestedPromptsAgent::fake([['suggested_prompts' => []]]);
$this->actingAs(User::factory()->create());
$portfolio = Portfolio::factory()->create();
Volt::test('ui.ai-chat-window', ['chatable' => $portfolio])
->set('messages', [['role' => 'user', 'content' => 'How is my portfolio doing?', 'created_at' => now()]])
->call('generateCompletion')
->assertSet('streaming', false);
ChatWithPortfolioAgent::assertPrompted('How is my portfolio doing?');
$this->assertDatabaseHas('agent_conversation_messages', [
'role' => 'assistant',
'content' => 'The portfolio looks great!',
]);
}
public function test_empty_prompt_returns_guidance_without_calling_agent(): void
{
ChatWithPortfolioAgent::fake(['Test response']);
ChatWithSuggestedPromptsAgent::fake([['suggested_prompts' => []]]);
$this->actingAs(User::factory()->create());
$portfolio = Portfolio::factory()->create();
$component = Volt::test('ui.ai-chat-window', ['chatable' => $portfolio])
->set('prompt', ' ')
->call('startCompletion');
$lastMessage = collect($component->get('messages'))->last()['content'];
$this->assertStringContainsString('Feel free to ask me a question!', $lastMessage);
ChatWithPortfolioAgent::assertNeverPrompted();
}
public function test_rate_limiting_blocks_excessive_requests(): void
{
ChatWithPortfolioAgent::fake(['Test response']);
ChatWithSuggestedPromptsAgent::fake([['suggested_prompts' => []]]);
$this->actingAs($user = User::factory()->create());
$portfolio = Portfolio::factory()->create();
for ($i = 0; $i < 20; $i++) {
RateLimiter::hit($user->id.'/'.$portfolio->id, 60);
}
$component = Volt::test('ui.ai-chat-window', ['chatable' => $portfolio])
->set('prompt', 'Am I rate limited?')
->call('startCompletion');
$lastMessage = collect($component->get('messages'))->last()['content'];
$this->assertStringContainsString("Hang on! You're doing that too much.", $lastMessage);
ChatWithPortfolioAgent::assertNeverPrompted();
}
public function test_suggested_prompt_is_used_as_prompt(): void
{
ChatWithPortfolioAgent::fake(['Here is the best holding!']);
ChatWithSuggestedPromptsAgent::fake([['suggested_prompts' => []]]);
$this->actingAs(User::factory()->create());
$portfolio = Portfolio::factory()->create();
Volt::test('ui.ai-chat-window', ['chatable' => $portfolio])
->call('startCompletion', 'Which holding is most successful in this portfolio?')
->assertSet('streaming', true)
->assertSee('Which holding is most successful in this portfolio?');
}
}
+18
View File
@@ -89,4 +89,22 @@ class DividendsTest extends TestCase
$this->assertEquals(4.95, $holdingOne->dividends_earned);
$this->assertEquals(8, $holdingTwo->dividends_earned);
}
public function test_dividend_earnings_not_shared_in_same_portfolio_with_multiple_symbols(): void
{
$this->actingAs($user = User::factory()->create());
$portfolio = Portfolio::factory()->create();
Transaction::factory()->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('ACME')->create();
Transaction::factory()->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('GOOG')->create();
Dividend::refreshDividendData('ACME');
$acmeHolding = Holding::query()->portfolio($portfolio->id)->symbol('ACME')->first();
$googHolding = Holding::query()->portfolio($portfolio->id)->symbol('GOOG')->first();
$this->assertEquals(4.95, $acmeHolding->dividends_earned);
$this->assertEquals(0, $googHolding->dividends_earned);
}
}