+page.svelte 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. <script>
  2. import { authentication } from '../store.js';
  3. import { onDestroy, onMount } from 'svelte';
  4. import { fade } from 'svelte/transition';
  5. import AddStock from '../../components/AddStock.svelte';
  6. import { getRequest } from '../../utils/api.js';
  7. import { goto } from '$app/navigation';
  8. import { browser } from '$app/environment';
  9. let portfolioId = undefined;
  10. let result = [];
  11. let totalValue = 0;
  12. let totalAssets = 0;
  13. let authToken;
  14. let isLoading = true;
  15. let showModal = false;
  16. let searchStockResult = [];
  17. let orderBy;
  18. let currency;
  19. let hasChanges = false;
  20. let showDeleteConfirm = false;
  21. let stockToDelete = null;
  22. function handleKeyDown(event) {
  23. if (event.key === 'Escape' && showDeleteConfirm) {
  24. cancelDelete();
  25. }
  26. if (event.key === 'Escape' && showModal) {
  27. closeModal();
  28. }
  29. }
  30. onMount(() => {
  31. if (browser) {
  32. window.addEventListener('keydown', handleKeyDown);
  33. }
  34. return authentication.subscribe(async (auth) => {
  35. if (new Date(auth?.expirationDate) < new Date()) {
  36. await goto('/logout');
  37. }
  38. if (!auth || !auth.token) {
  39. await goto('/logout');
  40. } else {
  41. const defaultCurrency = localStorage.getItem('defaultCurrency');
  42. currency = defaultCurrency || 'USD';
  43. const defaultOrder = localStorage.getItem('defaultOrder');
  44. orderBy = defaultOrder || 'total';
  45. authToken = auth.token;
  46. await fetchPortfolio();
  47. }
  48. });
  49. });
  50. onDestroy(() => {
  51. if (browser) {
  52. window.removeEventListener('keydown', handleKeyDown);
  53. }
  54. });
  55. async function fetchPortfolio() {
  56. try {
  57. const response = await getRequest(`${import.meta.env.VITE_STOCKS_HOST}/api/portfolios?currency=${currency}`, {}, authToken);
  58. if (response.ok) {
  59. await update(response.json());
  60. } else {
  61. const error = await response.json();
  62. console.error('Failed to find portfolio info:', error);
  63. }
  64. } catch (err) {
  65. console.error('Failed to find portfolio info', err);
  66. } finally {
  67. isLoading = false;
  68. }
  69. }
  70. async function update(response) {
  71. const portfolio = await response;
  72. if (portfolio?.length > 0) {
  73. if (orderBy === 'code') {
  74. result = portfolio[0].stocks.sort((a, b) => a.code.localeCompare(b.code));
  75. }
  76. if (orderBy === 'name') {
  77. result = portfolio[0].stocks.sort((a, b) => a.name.localeCompare(b.name));
  78. }
  79. if (orderBy === 'total') {
  80. result = portfolio[0].stocks.sort((a, b) => a.total - b.total);
  81. }
  82. if (orderBy === 'weight') {
  83. result = portfolio[0].stocks.sort((a, b) => b.total - a.total);
  84. }
  85. result = portfolio[0].stocks;
  86. totalValue = portfolio[0].totalValue;
  87. totalAssets = portfolio[0].totalAssets;
  88. portfolioId = portfolio[0].id;
  89. } else {
  90. await createNewPortfolio();
  91. }
  92. }
  93. async function createNewPortfolio() {
  94. try {
  95. const response = await fetch(`${import.meta.env.VITE_STOCKS_HOST}/api/portfolios`, {
  96. method: 'POST',
  97. headers: {
  98. Authorization: 'Bearer ' + authToken
  99. }
  100. });
  101. if (response.status === 400) {
  102. alert('Bad request. Invalid code.');
  103. return;
  104. }
  105. await fetchPortfolio();
  106. } catch (err) {
  107. console.error('Update failed', err);
  108. }
  109. }
  110. async function updatePortfolio(stocks) {
  111. try {
  112. const response = await fetch(
  113. `${import.meta.env.VITE_STOCKS_HOST}/api/portfolios/${portfolioId}`,
  114. {
  115. method: 'PUT',
  116. headers: {
  117. Authorization: 'Bearer ' + authToken,
  118. 'Content-Type': 'application/json'
  119. },
  120. body: JSON.stringify({ stocks })
  121. }
  122. );
  123. if (response.status === 400) {
  124. alert('Bad request. Invalid code.');
  125. return;
  126. }
  127. await fetchPortfolio();
  128. } catch (err) {
  129. console.error('Update failed', err);
  130. }
  131. }
  132. async function searchStock(code) {
  133. if (!code) return;
  134. try {
  135. const res = await fetch(`${import.meta.env.VITE_STOCKS_HOST}/api/stocks?q=${code}`);
  136. return await res.json();
  137. } catch (err) {
  138. console.error('Search error:', err);
  139. return [];
  140. }
  141. }
  142. async function addSelectedStock(newStock) {
  143. const exists = result.some((stock) => stock.code === newStock.code);
  144. if (exists) return;
  145. result = [
  146. ...result,
  147. {
  148. code: newStock.code,
  149. name: newStock.name,
  150. quantity: 0,
  151. price: newStock.price,
  152. total: 0,
  153. totalPercent: 0
  154. }
  155. ];
  156. closeModal();
  157. await updatePortfolio(result);
  158. }
  159. async function applyChanges() {
  160. try {
  161. await updatePortfolio(result);
  162. hasChanges = false;
  163. } catch (err) {
  164. console.error('Update failed', err);
  165. }
  166. }
  167. function remove(code) {
  168. result = result.filter((stock) => stock.code !== code);
  169. updatePortfolio(result);
  170. }
  171. function formatCurrency(value) {
  172. return value.toLocaleString('en-US', {
  173. style: 'currency',
  174. currency: currency
  175. });
  176. }
  177. function calculatePercentage(part, total) {
  178. return total ? Math.floor((part / total) * 10000) / 100 : 0;
  179. }
  180. function updateStockQuantity(e) {
  181. e.preventDefault();
  182. const form = new FormData(e.target);
  183. const code = form.get('code');
  184. const quantity = parseInt(form.get('quantity')) || 0;
  185. result = result.map((stock) => (stock.code === code ? { ...stock, quantity } : stock));
  186. updatePortfolio(result);
  187. }
  188. function openModal() {
  189. searchStockResult = [];
  190. showModal = true;
  191. }
  192. function closeModal() {
  193. searchStockResult = [];
  194. showModal = false;
  195. }
  196. function updateOrderBy(event) {
  197. orderBy = event.target.value;
  198. fetchPortfolio();
  199. }
  200. function updateCurrency(event) {
  201. currency = event.target.value;
  202. fetchPortfolio();
  203. }
  204. function formatCode(code) {
  205. return code.includes(':')
  206. ? code.split(':')[1]
  207. : code;
  208. }
  209. function getFlag(code) {
  210. const market = code.includes(':') ? code.split(':')[0].toUpperCase() : code.toUpperCase();
  211. const country = {
  212. BVMF: 'br',
  213. FRA: 'de',
  214. ETR: 'eu'
  215. };
  216. return country[market] || 'us';
  217. }
  218. function confirmDelete(code) {
  219. stockToDelete = code;
  220. showDeleteConfirm = true;
  221. }
  222. function cancelDelete() {
  223. stockToDelete = null;
  224. showDeleteConfirm = false;
  225. }
  226. function confirmDeleteAction() {
  227. if (stockToDelete) {
  228. remove(stockToDelete);
  229. stockToDelete = null;
  230. }
  231. showDeleteConfirm = false;
  232. }
  233. function handleInputChange(event) {
  234. const form = new FormData(event.target.closest('form'));
  235. const code = form.get('code');
  236. const quantity = parseInt(form.get('quantity')) || 0;
  237. result = result.map((stock) => (stock.code === code ? { ...stock, quantity } : stock));
  238. hasChanges = true;
  239. }
  240. function openStock(code) {
  241. goto(`/stocks/${code}`);
  242. }
  243. </script>
  244. <svelte:head>
  245. <title>Portfolio</title>
  246. <meta name="description" content="Portfolio" />
  247. </svelte:head>
  248. {#if isLoading}
  249. <div in:fade class="text-center py-6 text-gray-500 dark:text-gray-300">Loading...</div>
  250. {:else if portfolioId}
  251. <div class="flex flex-wrap gap-4 mb-6 items-center">
  252. <button
  253. class="bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium px-4 py-2 rounded-lg shadow"
  254. on:click={openModal}
  255. >
  256. Add
  257. </button>
  258. <select
  259. class="w-40 px-3 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-400"
  260. on:change={updateCurrency}
  261. bind:value={currency}
  262. >
  263. <option value="BRL">BRL</option>
  264. <option value="EUR">EUR</option>
  265. <option value="USD">USD</option>
  266. </select>
  267. <select
  268. class="w-52 px-3 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-400"
  269. on:change={updateOrderBy}
  270. bind:value={orderBy}
  271. >
  272. <option value="code">Order by Code</option>
  273. <option value="name">Order by Name</option>
  274. <option value="total">Order by Total</option>
  275. <option value="weight">Order by Weight</option>
  276. </select>
  277. </div>
  278. <AddStock
  279. show={showModal}
  280. onClose={closeModal}
  281. onSearch={async (code) => {
  282. const data = await searchStock(code);
  283. if (!data || data.length === 0) {
  284. alert('Stock not found.');
  285. return;
  286. }
  287. searchStockResult = data;
  288. const alreadyInPortfolio = result.some((s) => s.code === data[0]?.code);
  289. if (data.length === 1 && !alreadyInPortfolio) {
  290. await addSelectedStock(data[0]);
  291. closeModal();
  292. }
  293. }}
  294. onAddStock={async (stock) => {
  295. await addSelectedStock(stock);
  296. closeModal();
  297. }}
  298. searchResults={searchStockResult}
  299. />
  300. {#if showDeleteConfirm}
  301. <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">
  302. <div class="flex justify-center gap-4">
  303. <button class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg" on:click={confirmDeleteAction}>Confirm deletion</button>
  304. <button class="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded-lg" on:click={cancelDelete}>Cancel</button>
  305. </div>
  306. </div>
  307. {/if}
  308. <div in:fade class="overflow-x-auto mt-6 rounded-xl shadow">
  309. <table class="min-w-full bg-white dark:bg-gray-900 text-sm">
  310. <thead class="bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">
  311. <tr>
  312. <th class="px-4 py-3 text-left font-semibold">Total Value</th>
  313. <th class="px-4 py-3 text-left font-semibold">Total Assets</th>
  314. </tr>
  315. </thead>
  316. <tbody>
  317. <tr class="border-t border-gray-200 dark:border-gray-700">
  318. <td class="px-4 py-3 font-semibold text-gray-800 dark:text-gray-100">{formatCurrency(totalValue)}</td>
  319. <td class="px-4 py-3 font-semibold text-gray-800 dark:text-gray-100">{totalAssets}</td>
  320. </tr>
  321. </tbody>
  322. </table>
  323. </div>
  324. <div in:fade class="overflow-x-auto mt-6 rounded-xl shadow">
  325. <table class="min-w-full bg-white dark:bg-gray-900 text-sm">
  326. <thead class="bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">
  327. <tr>
  328. <th class="px-4 py-3">Code</th>
  329. <th class="px-4 py-3">Name</th>
  330. <th class="px-4 py-3">Qty</th>
  331. <th class="px-4 py-3">Price</th>
  332. <th class="px-4 py-3">Total</th>
  333. <th class="px-4 py-3">% of Portfolio</th>
  334. <th class="px-4 py-3"></th>
  335. </tr>
  336. </thead>
  337. <tbody>
  338. {#each result as stock}
  339. <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">
  340. <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)}>
  341. <div class="inline-flex items-center gap-2">
  342. <img src={`https://flagcdn.com/w20/${getFlag(stock.code)}.png`} alt="{getFlag(stock.code)} flag" class="w-5 h-auto" />
  343. {formatCode(stock.code)}
  344. </div>
  345. </td>
  346. <td class="px-4 py-2 text-gray-700 dark:text-gray-300">{stock.name}</td>
  347. <td class="px-4 py-2">
  348. <form id="updateQuantity" on:submit|preventDefault={updateStockQuantity} class="flex items-center">
  349. <input type="hidden" name="code" value={stock.code} />
  350. <input type="number" name="quantity" min="0" value={stock.quantity}
  351. 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} />
  352. </form>
  353. </td>
  354. <td class="px-4 py-2 font-semibold text-green-600 dark:text-green-400">{formatCurrency(stock.price)}</td>
  355. <td class="px-4 py-2 font-semibold text-gray-800 dark:text-gray-200">{formatCurrency(stock.total)}</td>
  356. <td class="px-4 py-2 text-blue-600 dark:text-blue-400">{calculatePercentage(stock.total, totalValue)}%</td>
  357. <td class="px-4 py-2 text-right">
  358. <button on:click={() => confirmDelete(stock.code)} aria-label="Delete" title="remove"
  359. class="bg-red-600 hover:bg-red-700 text-white rounded-full p-2 shadow focus:outline-none focus:ring-2 focus:ring-red-400">
  360. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4">
  361. <polyline points="3 6 5 6 21 6"></polyline>
  362. <path d="M19 6l-1 14H6L5 6"></path>
  363. <path d="M10 11v6"></path>
  364. <path d="M14 11v6"></path>
  365. <path d="M9 6V4h6v2"></path>
  366. </svg>
  367. </button>
  368. </td>
  369. </tr>
  370. {/each}
  371. </tbody>
  372. </table>
  373. </div>
  374. {#if hasChanges}
  375. <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}>
  376. Apply Changes
  377. </button>
  378. {/if}
  379. {:else}
  380. <div class="text-gray-500 dark:text-gray-300 text-center py-6">No portfolio data available.</div>
  381. {/if}