+page.svelte 13 KB

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