Feat: Adds multi currency support (#88)

This commit is contained in:
hackerESQ
2025-04-09 19:25:15 -05:00
committed by GitHub
parent 6d6f968f42
commit eae345f243
100 changed files with 17735 additions and 35761 deletions
@@ -0,0 +1,123 @@
<?php
use App\Models\Portfolio;
use Illuminate\Support\Collection;
use Livewire\Attributes\Rule;
use Livewire\Volt\Component;
use Mary\Traits\Toast;
use App\Traits\WithTrimStrings;
new class extends Component {
use Toast;
use WithTrimStrings;
// props
public ?Portfolio $portfolio;
public Bool $hideCancel = false;
#[Rule('required|min:5')]
public String $title;
#[Rule('sometimes|nullable')]
public ?String $notes;
#[Rule('sometimes|nullable|boolean')]
public Bool $wishlist = false;
public Bool $confirmingPortfolioDeletion = false;
// methods
public function mount()
{
if (isset($this->portfolio)) {
$this->title = $this->portfolio->title;
$this->notes = $this->portfolio->notes;
$this->wishlist = $this->portfolio->wishlist;
}
}
public function update()
{
$this->authorize('fullAccess', $this->portfolio);
$this->portfolio->update($this->validate());
$this->portfolio->save();
$this->success(__('Portfolio updated'), redirectTo: "/portfolio/{$this->portfolio->id}");
}
public function save()
{
$portfolio = (new Portfolio())->fill($this->validate());
$portfolio->save();
$this->success(__('Portfolio created'), redirectTo: "/portfolio/{$portfolio->id}");
}
public function delete()
{
$this->authorize('fullAccess', $this->portfolio);
$this->portfolio->delete();
$this->success(__('Portfolio deleted'), redirectTo: route('dashboard'));
}
}; ?>
<div class="w-full md:w-3/4">
<x-ib-form wire:submit="{{ $portfolio ? 'update' : 'save' }}" >
<x-input label="{{ __('Title') }}" wire:model="title" required />
<x-ib-textarea class="mt-1" label="{{ __('Notes') }}" wire:model="notes" rows="4" />
@if (isset($this->portfolio))
@livewire('share-portfolio-form', ['portfolio' => $portfolio])
@endif
<x-toggle label="{{ __('Wishlist') }}" wire:model="wishlist" >
<x-slot:hint>
{{ __('Treat this portfolio as a "wishlist" (holdings will be excluded from realized gains, unrealized gains, and dividends)') }}
</x-slot:hint>
</x-toggle>
<x-slot:actions>
@if ($portfolio)
<x-button
wire:click="$toggle('confirmingPortfolioDeletion')"
wire:loading.attr="disabled"
class="btn text-error"
title="{{ __('Delete Portfolio') }}"
label="{{ __('Delete Portfolio') }}"
/>
@endif
@if (!$hideCancel)
<x-button label="{{ __('Cancel') }}" link="/dashboard" />
@endif
<x-button label="{{ $portfolio ? __('Update') : __('Create') }}" type="submit" icon="o-paper-airplane" class="btn-primary" spinner="save" />
</x-slot:actions>
</x-ib-form>
<x-confirmation-modal wire:model.live="confirmingPortfolioDeletion">
<x-slot name="title">
{{ __('Delete Portfolio') }}
</x-slot>
<x-slot name="content">
{{ __('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.') }}
</x-slot>
<x-slot name="footer">
<x-button class="btn-outline" wire:click="$toggle('confirmingPortfolioDeletion')" wire:loading.attr="disabled">
{{ __('Cancel') }}
</x-secondary-button>
<x-button class="ms-3 btn-error text-white" wire:click="delete" wire:loading.attr="disabled">
{{ __('Delete Portfolio') }}
</x-button>
</x-slot>
</x-confirmation-modal>
</div>
@@ -0,0 +1,156 @@
<?php
use App\Models\DailyChange;
use App\Models\Portfolio;
use Livewire\Volt\Component;
new class extends Component
{
// props
public ?Portfolio $portfolio;
public string $name = 'portfolio';
public string $scope = 'YTD';
public array $scopeOptions = [
['id' => '1M', 'name' => '1 month', 'method' => 'subMonths', 'args' => [1]],
['id' => '3M', 'name' => '3 months', 'method' => 'subMonths', 'args' => [3]],
['id' => 'YTD', 'name' => 'Year to date', 'method' => 'startOfYear', 'args' => []],
['id' => '1Y', 'name' => '1 year', 'method' => 'subYears', 'args' => [1]],
['id' => '3Y', 'name' => '3 years', 'method' => 'subYears', 'args' => [3]],
['id' => 'ALL', 'name' => 'All time', 'method' => null],
];
// data
public array $chartSeries;
// methods
public function mount()
{
$this->chartSeries = $this->generatePerformanceData();
}
public function generatePerformanceData()
{
$filterMethod = collect($this->scopeOptions)->where('id', $this->scope)->first();
$dailyChangeQuery = DailyChange::withDailyPerformance();
if (isset($this->portfolio)) {
// portfolio
$dailyChangeQuery->portfolio($this->portfolio->id);
} else {
// dashboard
$dailyChangeQuery->myDailyChanges()->withoutWishlists();
}
if ($filterMethod['method']) {
$dailyChangeQuery->whereDate('daily_change.date', '>=', now()->{$filterMethod['method']}(...$filterMethod['args']));
}
$dailyChange = $dailyChangeQuery->get();
$dailyChange = $dailyChange
->sortBy('date')
->groupBy('date')
->map(function ($group) {
return (object) [
'date' => $group->first()->date->toDateString(),
'total_market_value' => $group->sum('total_market_value'),
'total_cost_basis' => $group->sum('total_cost_basis'),
'total_gain' => $group->sum('total_gain'),
'realized_gain_dollars' => $group->sum('realized_gain_dollars'),
'total_dividends_earned' => $group->sum('total_dividends_earned'),
];
})
->values();
return [
'series' => [
[
'name' => __('Market Value'),
'data' => $dailyChange->map(fn ($data) => [$data->date, $data->total_market_value])->toArray(),
],
[
'name' => __('Cost Basis'),
'data' => $dailyChange->map(fn ($data) => [$data->date, $data->total_cost_basis])->toArray(),
],
[
'name' => __('Market Gain'),
'data' => $dailyChange->map(fn ($data) => [$data->date, $data->total_gain])->toArray(),
],
// [
// 'name' => __('Dividends Earned'),
// 'data' => $dailyChange->map(fn($data) => [$data->date, $data->total_dividends_earned])->toArray()
// ],
// [
// 'name' => __('Realized Gains'),
// 'data' => $dailyChange->map(fn($data) => [$data->date, $data->realized_gains])->toArray()
// ],
],
];
}
public function changeScope($scope)
{
$this->scope = $scope;
$this->chartSeries = $this->generatePerformanceData();
}
public function getScopeName($scope)
{
return collect($this->scopeOptions)->where('id', $scope)->first()['name'];
}
}; ?>
<x-card class="bg-slate-100 dark:bg-base-200 rounded-lg mb-6">
<div class="flex flex-col md:flex-row md:justify-between mb-2">
<div class="flex flex-col md:flex-row items-start md:items-center">
<h2 class="text-xl mb-2 md:mb-0 md:mr-4">{{ __('Performance') }}</h2>
<div id="chart-legend-{{ $name }}" class="flex space-between whitespace-nowrap mb-2 md:mb-0"></div>
</div>
<div class="flex items-center" x-data="{ loading: false }">
{{-- <x-button title="{{ __('Reset chart') }}" icon="o-arrow-path" class="btn-ghost btn-sm btn-circle mr-2" id="chart-reset-zoom-{{ $name }}" /> --}}
<x-loading x-show="loading" x-cloak class="text-gray-400 ml-2" />
<x-dropdown title="{{ __('Choose time period') }}" label="{{ $scope }}" class="btn-xs md:btn-sm btn-outline" x-bind:disabled="loading">
@foreach($scopeOptions as $option)
<x-menu-item
title="{{ $option['name'] }}"
@click="
timeout = setTimeout(() => { loading = true }, 200);
$wire.changeScope('{{ $option['id'] }}').then(() => {
clearTimeout(timeout);
loading = false;
})
"
/>
@endforeach
</x-dropdown>
</div>
</div>
<div
class="h-[280px] mb-5"
>
<x-ib-apex-chart :series-data="$chartSeries" :name="$name" />
</div>
</x-card>
@@ -0,0 +1,241 @@
<?php
use App\Models\Portfolio;
use App\Models\User;
use App\Traits\WithTrimStrings;
use Livewire\Attributes\Rule;
use Livewire\Volt\Component;
use Illuminate\Support\Collection;
use Mary\Traits\Toast;
new class extends Component {
use Toast;
use WithTrimStrings;
// props
public ?Portfolio $portfolio = null;
#[Rule('required|string|email')]
public string $emailAddress;
#[Rule('sometimes|boolean')]
public int $fullAccess = 0;
public array $permissions;
public bool $confirmingAccessDeletion = false;
public ?string $deletingAccessFor = null;
// methods
public function mount()
{
if (!$this->portfolio) {
$this->permissions = [
auth()->user()->id => [
'owner' => true,
'full_access' => false
]
];
} else {
$this->permissions = collect($this->portfolio->users)->mapWithKeys(function ($user) {
return [
$user->id => [
'owner' => $user->pivot->owner ?? 0,
'full_access' => $user->pivot->full_access ?? 0
]
];
})->toArray();
}
}
public function updatedPermissions()
{
$this->authorize('fullAccess', $this->portfolio);
$this->portfolio->users()->sync($this->permissions);
$this->portfolio->refresh();
$this->success(__('Updated user\'s access permission to portfolio'));
}
public function deleteUser(string $userId, bool $confirmed = false)
{
$this->authorize('fullAccess', $this->portfolio);
if (!$confirmed) {
$this->deletingAccessFor = $userId;
$this->confirmingAccessDeletion = true;
return;
}
unset($this->permissions[$userId]);
$this->portfolio->unShare($userId);
$this->portfolio->refresh();
$this->success(__('Removed user\'s access to portfolio'));
// reset
$this->confirmingAccessDeletion = false;
$this->deletingAccessFor = null;
}
public function addUser()
{
$this->authorize('fullAccess', $this->portfolio);
$this->validate();
$this->portfolio->share($this->emailAddress, $this->fullAccess);
$this->success(__('Shared portfolio with user'));
$this->portfolio->refresh();
$this->dispatch('toggle-add-user-modal');
$this->emailAddress = '';
$this->fullAccess = false;
}
}; ?>
<div class="">
<label class="pt-0 label label-text font-semibold">
<span>{{ __('People with access') }}</span>
</label>
<div class="border-primary border rounded-sm px-2 py-5 mb-2 max-h-[20rem] overflow-y-scroll">
@if ($portfolio?->owner)
<x-list-item
:item="$portfolio->owner"
avatar="profile_photo_url"
no-separator
no-hover
class="!-my-2 rounded"
>
<x-slot:value>
{{ $portfolio->owner->name }}
@if (auth()->user()->id == $portfolio->owner->id)
({{ __('you') }})
@endif
</x-slot:value>
<x-slot:sub-value>
{{ __('Owner') }}
</x-slot:sub-value>
</x-list-item>
@endif
@foreach (collect($portfolio?->users)->where('pivot.owner', '!=', 1) as $user)
<x-list-item
:item="$user"
avatar="profile_photo_url"
no-separator
class="!-my-2 rounded"
x-data="{ loading: false, timeout: null }"
>
<x-slot:value>
{{ $user->name }}
@if (auth()->user()->id == $user->id)
({{ __('you') }})
@endif
</x-slot:value>
<x-slot:sub-value>
{{ $user->email }}
</x-slot:sub-value>
<x-slot:actions>
@if (auth()->user()->id != $user->id)
<x-select
class="select 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"
/>
<x-button
class="btn-sm btn-ghost btn-circle"
wire:click="deleteUser('{{ $user->id }}')"
spinner="deleteUser('{{ $user->id }}')"
title="{{ __('Remove Access') }}"
>
<x-icon name="o-x-mark" class="w-4" />
</x-button>
@endif
</x-slot:actions>
</x-list-item>
@endforeach
<x-confirmation-modal wire:model.live="confirmingAccessDeletion">
<x-slot:title>
{{ __('Remove Access') }}
</x-slot:title>
<x-slot name="content">
{{ __('By removing this person\'s access, they will no longer be able to view this portfolio. They will lose access immediately.') }}
</x-slot>
<x-slot name="footer">
<x-button class="btn-outline" wire:click="$toggle('confirmingAccessDeletion')" wire:loading.attr="disabled">
{{ __('Cancel') }}
</x-secondary-button>
<x-button class="ms-3 btn-error text-white" wire:click="deleteUser('{{ $this->deletingAccessFor }}', true)" spinner="deleteUser" wire:loading.attr="disabled">
{{ __('Remove Access') }}
</x-button>
</x-slot>
</x-confirmation-modal>
<x-ib-alpine-modal
key="add-user-modal"
title="{{ __('Share Portfolio') }}"
>
<div class="" x-data="{ }">
<x-ib-form wire:submit="addUser" class="">
<x-input
label="Email"
icon="o-envelope"
placeholder="{{ __('Type an email address to share portfolio') }}"
wire:model="emailAddress"
type="email"
required
/>
<x-toggle
class="mt-2"
label="{{ __('Grant full access') }}"
wire:model="fullAccess"
hint="{{ __('Allow this user to manage portfolio details and create or update transactions') }}"
right
/>
<x-slot:actions>
<x-button
label="{{ __('Share') }}"
title="{{ __('Share Portfolio') }}"
type="submit"
icon="o-paper-airplane"
class="btn-primary"
spinner="addUser"
/>
</x-slot:actions>
</x-ib-form>
</div>
</x-ib-alpine-modal>
<x-button class="btn-sm block mt-4" @click="$dispatch('toggle-add-user-modal')">
{{ __('Add People') }}
</x-button>
</div>
</div>
+8 -7
View File
@@ -1,3 +1,5 @@
@use('App\Models\Currency')
<x-app-layout>
<div x-data>
@@ -63,30 +65,29 @@
<div class="grid sm:grid-cols-5 gap-5">
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Market Gain/Loss') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->total_gain_dollars) }} </div>
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_gain_dollars', 0)) }} </div>
</x-card>
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Total Cost Basis') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->total_cost_basis) }} </div>
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_cost_basis', 0)) }} </div>
</x-card>
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Total Market Value') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->total_market_value) }} </div>
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_market_value', 0)) }} </div>
</x-card>
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Realized Gain/Loss') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->realized_gain_dollars) }} </div>
<div class="font-black text-xl"> {{ Number::currency($metrics->get('realized_gain_dollars', 0)) }} </div>
</x-card>
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Dividends Earned') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->total_dividends_earned) }} </div>
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_dividends_earned', 0)) }} </div>
</x-card>
</div>
@@ -175,7 +176,7 @@
{$formattedHoldings}
This data is current as of today's date: " . now()->format('Y-m-d') . ". Based on the current market data, quantity owned, and average cost basis, you can determine the performance of any holding.
This data is current as of today's date: " . now()->toDateString() . ". 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:"
])
@@ -0,0 +1,50 @@
<?php
use App\Models\Portfolio;
use App\Models\Transaction;
use Illuminate\Support\Collection;
use Livewire\Volt\Component;
new class extends Component {
// props
public Collection $holdings;
// methods
}; ?>
<div class="">
@foreach(
$holdings->sortByDesc('market_gain_percent')
->where('quantity', '>', 0)
->where('market_data.market_value', '>', 0)
->take(5)
as $holding
)
<x-list-item
no-separator
:item="$holding"
link="{{ route('holding.show', [
'portfolio' => $holding->portfolio_id,
'symbol' => $holding->symbol,
]) }}"
>
<x-slot:value class="flex items-center">
{{ $holding->market_data?->name }} ({{ $holding->symbol }})
<x-gain-loss-arrow-badge
:cost-basis="$holding->average_cost_basis"
:market-value="$holding->market_data->market_value"
/>
</x-slot:value>
<x-slot:sub-value>
{{ $holding->portfolio->title }}
</x-slot:sub-value>
</x-list-item>
@endforeach
</div>