| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502 |
- <script>
- import { authentication } from '../store.js';
- import { onDestroy, onMount } from 'svelte';
- import { fade } from 'svelte/transition';
- import AddStock from '../../components/AddStock.svelte';
- import { getRequest } from '../../utils/api.js';
- import { goto } from '$app/navigation';
- import { browser } from '$app/environment';
- let portfolioId = undefined;
- let result = [];
- let totalValue = 0;
- let totalAssets = 0;
- let authToken;
- let isLoading = true;
- let showModal = false;
- let searchStockResult = [];
- let orderBy;
- let currency;
- let hasChanges = false;
- let showDeleteConfirm = false;
- let stockToDelete = null;
- function handleKeyDown(event) {
- if (event.key === 'Escape' && showDeleteConfirm) {
- cancelDelete();
- }
- if (event.key === 'Escape' && showModal) {
- closeModal();
- }
- }
- onMount(() => {
- if (browser) {
- window.addEventListener('keydown', handleKeyDown);
- }
- return authentication.subscribe(async (auth) => {
- if (new Date(auth?.expirationDate) < new Date()) {
- await goto('/logout');
- }
- if (!auth || !auth.token) {
- await goto('/logout');
- } else {
- const defaultCurrency = localStorage.getItem('defaultCurrency');
- currency = defaultCurrency || 'USD';
- const defaultOrder = localStorage.getItem('defaultOrder');
- orderBy = defaultOrder || 'total';
- authToken = auth.token;
- await fetchPortfolio();
- }
- });
- });
- onDestroy(() => {
- if (browser) {
- window.removeEventListener('keydown', handleKeyDown);
- }
- });
- async function fetchPortfolio() {
- try {
- const response = await getRequest(
- `${import.meta.env.VITE_STOCKS_HOST}/api/portfolios?currency=${currency}`,
- {},
- authToken
- );
- if (response.ok) {
- await update(response.json());
- } else {
- const error = await response.json();
- console.error('Failed to find portfolio info:', error);
- }
- } catch (err) {
- console.error('Failed to find portfolio info', err);
- } finally {
- isLoading = false;
- }
- }
- async function update(response) {
- const portfolio = await response;
- if (portfolio?.length > 0) {
- if (orderBy === 'code') {
- result = portfolio[0].stocks.sort((a, b) => a.code.localeCompare(b.code));
- }
- if (orderBy === 'name') {
- result = portfolio[0].stocks.sort((a, b) => a.name.localeCompare(b.name));
- }
- if (orderBy === 'total') {
- result = portfolio[0].stocks.sort((a, b) => a.total - b.total);
- }
- if (orderBy === 'weight') {
- result = portfolio[0].stocks.sort((a, b) => b.total - a.total);
- }
- result = portfolio[0].stocks;
- totalValue = portfolio[0].totalValue;
- totalAssets = portfolio[0].totalAssets;
- portfolioId = portfolio[0].id;
- } else {
- await createNewPortfolio();
- }
- }
- async function createNewPortfolio() {
- try {
- const response = await fetch(`${import.meta.env.VITE_STOCKS_HOST}/api/portfolios`, {
- method: 'POST',
- headers: {
- Authorization: 'Bearer ' + authToken
- }
- });
- if (response.status === 400) {
- alert('Bad request. Invalid code.');
- return;
- }
- await fetchPortfolio();
- } catch (err) {
- console.error('Update failed', err);
- }
- }
- async function updatePortfolio(stocks) {
- try {
- const response = await fetch(
- `${import.meta.env.VITE_STOCKS_HOST}/api/portfolios/${portfolioId}`,
- {
- method: 'PUT',
- headers: {
- Authorization: 'Bearer ' + authToken,
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({ stocks })
- }
- );
- if (response.status === 400) {
- alert('Bad request. Invalid code.');
- return;
- }
- await fetchPortfolio();
- } catch (err) {
- console.error('Update failed', err);
- }
- }
- async function searchStock(code) {
- if (!code) return;
- try {
- const res = await fetch(`${import.meta.env.VITE_STOCKS_HOST}/api/stocks?q=${code}`);
- return await res.json();
- } catch (err) {
- console.error('Search error:', err);
- return [];
- }
- }
- async function addSelectedStock(newStock) {
- const exists = result.some((stock) => stock.code === newStock.code);
- if (exists) return;
- result = [
- ...result,
- {
- code: newStock.code,
- name: newStock.name,
- quantity: 0,
- price: newStock.price,
- total: 0,
- totalPercent: 0
- }
- ];
- closeModal();
- await updatePortfolio(result);
- }
- async function applyChanges() {
- try {
- await updatePortfolio(result);
- hasChanges = false;
- } catch (err) {
- console.error('Update failed', err);
- }
- }
- function remove(code) {
- result = result.filter((stock) => stock.code !== code);
- updatePortfolio(result);
- }
- function formatCurrency(value) {
- return value.toLocaleString('en-US', {
- style: 'currency',
- currency: currency
- });
- }
- function calculatePercentage(part, total) {
- return total ? Math.floor((part / total) * 10000) / 100 : 0;
- }
- function updateStockQuantity(e) {
- e.preventDefault();
- const form = new FormData(e.target);
- const code = form.get('code');
- const quantity = parseInt(form.get('quantity')) || 0;
- result = result.map((stock) => (stock.code === code ? { ...stock, quantity } : stock));
- updatePortfolio(result);
- }
- function openModal() {
- searchStockResult = [];
- showModal = true;
- }
- function closeModal() {
- searchStockResult = [];
- showModal = false;
- }
- function updateOrderBy(event) {
- orderBy = event.target.value;
- fetchPortfolio();
- }
- function updateCurrency(event) {
- currency = event.target.value;
- fetchPortfolio();
- }
- function formatCode(code) {
- return code.includes(':') ? code.split(':')[1] : code;
- }
- function getFlag(code) {
- const market = code.includes(':') ? code.split(':')[0].toUpperCase() : code.toUpperCase();
- const country = {
- BVMF: 'br',
- FRA: 'de',
- ETR: 'eu'
- };
- return country[market] || 'us';
- }
- function confirmDelete(code) {
- stockToDelete = code;
- showDeleteConfirm = true;
- }
- function cancelDelete() {
- stockToDelete = null;
- showDeleteConfirm = false;
- }
- function confirmDeleteAction() {
- if (stockToDelete) {
- remove(stockToDelete);
- stockToDelete = null;
- }
- showDeleteConfirm = false;
- }
- function handleInputChange(event) {
- const form = new FormData(event.target.closest('form'));
- const code = form.get('code');
- const quantity = parseInt(form.get('quantity')) || 0;
- result = result.map((stock) => (stock.code === code ? { ...stock, quantity } : stock));
- hasChanges = true;
- }
- function openStock(code) {
- goto(`/stocks/${code}`);
- }
- </script>
- <svelte:head>
- <title>Portfolio</title>
- <meta name="description" content="Portfolio" />
- </svelte:head>
- {#if isLoading}
- <div in:fade class="flex justify-center items-center py-10">
- <svg
- class="animate-spin h-8 w-8 text-blue-500 dark:text-blue-300"
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- >
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
- ></circle>
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
- </svg>
- </div>
- {:else if portfolioId}
- <div class="flex flex-wrap gap-4 mb-6 items-center">
- <button
- class="bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium px-4 py-2 rounded-lg shadow"
- on:click={openModal}
- >
- Add
- </button>
- <select
- class="w-40 px-3 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-400"
- on:change={updateCurrency}
- bind:value={currency}
- >
- <option value="BRL">BRL</option>
- <option value="EUR">EUR</option>
- <option value="USD">USD</option>
- </select>
- <select
- class="w-52 px-3 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-400"
- on:change={updateOrderBy}
- bind:value={orderBy}
- >
- <option value="code">Order by Code</option>
- <option value="name">Order by Name</option>
- <option value="total">Order by Total</option>
- <option value="weight">Order by Weight</option>
- </select>
- </div>
- <AddStock
- show={showModal}
- onClose={closeModal}
- onSearch={async (code) => {
- const data = await searchStock(code);
- if (!data || data.length === 0) {
- alert('Stock not found.');
- return;
- }
- searchStockResult = data;
- const alreadyInPortfolio = result.some((s) => s.code === data[0]?.code);
- if (data.length === 1 && !alreadyInPortfolio) {
- await addSelectedStock(data[0]);
- closeModal();
- }
- }}
- onAddStock={async (stock) => {
- await addSelectedStock(stock);
- closeModal();
- }}
- searchResults={searchStockResult}
- />
- {#if showDeleteConfirm}
- <div
- class="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-800 p-6 rounded-xl shadow-lg z-50 text-center"
- >
- <div class="flex justify-center gap-4">
- <button
- class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg"
- on:click={confirmDeleteAction}>Confirm deletion</button
- >
- <button
- class="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded-lg"
- on:click={cancelDelete}>Cancel</button
- >
- </div>
- </div>
- {/if}
- <div in:fade class="overflow-x-auto mt-6 rounded-xl shadow">
- <table class="min-w-full bg-white dark:bg-gray-900 text-sm">
- <thead class="bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">
- <tr>
- <th class="px-4 py-3 text-left font-semibold">Total Value</th>
- <th class="px-4 py-3 text-left font-semibold">Total Assets</th>
- </tr>
- </thead>
- <tbody>
- <tr class="border-t border-gray-200 dark:border-gray-700">
- <td class="px-4 py-3 font-semibold text-gray-800 dark:text-gray-100"
- >{formatCurrency(totalValue)}</td
- >
- <td class="px-4 py-3 font-semibold text-gray-800 dark:text-gray-100">{totalAssets}</td>
- </tr>
- </tbody>
- </table>
- </div>
- <div in:fade class="overflow-x-auto mt-6 rounded-xl shadow">
- <table class="min-w-full bg-white dark:bg-gray-900 text-sm">
- <thead class="bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">
- <tr>
- <th class="px-4 py-3">Code</th>
- <th class="px-4 py-3">Name</th>
- <th class="px-4 py-3">Qty</th>
- <th class="px-4 py-3">Price</th>
- <th class="px-4 py-3">Total</th>
- <th class="px-4 py-3">% of Portfolio</th>
- <th class="px-4 py-3"></th>
- </tr>
- </thead>
- <tbody>
- {#each result as stock}
- <tr
- class="odd:bg-gray-50 even:bg-gray-100 dark:odd:bg-gray-800 dark:even:bg-gray-700 hover:bg-blue-50 dark:hover:bg-blue-900 transition"
- >
- <td
- class="px-4 py-2 font-mono font-semibold text-blue-700 dark:text-blue-300 cursor-pointer"
- title={stock.code}
- on:click={() => openStock(stock.code)}
- >
- <div class="inline-flex items-center gap-2">
- <img
- src={`https://flagcdn.com/w40/${getFlag(stock.code)}.png`}
- alt="{getFlag(stock.code)} flag"
- class="w-5 h-auto"
- />
- {formatCode(stock.code)}
- </div>
- </td>
- <td class="px-4 py-2 text-gray-700 dark:text-gray-300">{stock.name}</td>
- <td class="px-4 py-2">
- <form
- id="updateQuantity"
- on:submit|preventDefault={updateStockQuantity}
- class="flex items-center"
- >
- <input type="hidden" name="code" value={stock.code} />
- <input
- type="number"
- name="quantity"
- min="0"
- value={stock.quantity}
- class="w-16 px-2 py-1 text-right border rounded-lg text-sm text-gray-800 dark:text-gray-100 bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-300"
- on:input={handleInputChange}
- />
- </form>
- </td>
- <td class="px-4 py-2 font-semibold text-green-600 dark:text-green-400"
- >{formatCurrency(stock.price)}</td
- >
- <td class="px-4 py-2 font-semibold text-gray-800 dark:text-gray-200"
- >{formatCurrency(stock.total)}</td
- >
- <td class="px-4 py-2 text-blue-600 dark:text-blue-400"
- >{calculatePercentage(stock.total, totalValue)}%</td
- >
- <td class="px-4 py-2 text-right">
- <button
- on:click={() => confirmDelete(stock.code)}
- class="inline-flex items-center justify-center p-2 rounded-full text-red-600 hover:text-white hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-400 transition"
- aria-label="Delete stock"
- title="Delete"
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- stroke-width="2"
- class="w-5 h-5"
- >
- <polyline points="3 6 5 6 21 6"></polyline>
- <path d="M19 6l-1 14H6L5 6"></path>
- <path d="M10 11v6"></path>
- <path d="M14 11v6"></path>
- <path d="M9 6V4h6v2"></path>
- </svg>
- </button>
- </td>
- </tr>
- {/each}
- </tbody>
- </table>
- </div>
- {#if hasChanges}
- <button
- class="fixed bottom-5 right-5 bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg z-50"
- on:click={applyChanges}
- >
- Apply Changes
- </button>
- {/if}
- {:else}
- <div class="text-gray-500 dark:text-gray-300 text-center py-6">No portfolio data available.</div>
- {/if}
|