+page.svelte 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. <script>
  2. import { authentication } from '../store.js';
  3. import { 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. let portfolioId = undefined;
  9. let result = [];
  10. let totalValue = 0;
  11. let totalAssets = 0;
  12. let authToken;
  13. let isLoading = true;
  14. let showModal = false;
  15. let searchStockResult = [];
  16. let orderBy = 'total';
  17. let currency = 'USD';
  18. let hasChanges = false;
  19. onMount(() => {
  20. return authentication.subscribe(async (auth) => {
  21. if (!auth || !auth.token) {
  22. await goto('/login');
  23. } else {
  24. authToken = auth.token;
  25. await fetchPortfolio();
  26. }
  27. });
  28. });
  29. async function fetchPortfolio() {
  30. try {
  31. const response = await getRequest(`${import.meta.env.VITE_STOCKS_HOST}/api/portfolios?currency=${currency}`, {}, authToken);
  32. if (response.ok) {
  33. await update(response.json());
  34. } else {
  35. const error = await response.json();
  36. console.error('Failed to find portfolio info:', error);
  37. }
  38. } catch (err) {
  39. console.error('Failed to find portfolio info', err);
  40. } finally {
  41. isLoading = false;
  42. }
  43. }
  44. async function update(response) {
  45. const portfolio = await response;
  46. if (portfolio?.length > 0) {
  47. if (orderBy === 'code') {
  48. result = portfolio[0].stocks.sort((a, b) => a.code.localeCompare(b.code));
  49. }
  50. if (orderBy === 'name') {
  51. result = portfolio[0].stocks.sort((a, b) => a.name.localeCompare(b.name));
  52. }
  53. if (orderBy === 'total') {
  54. result = portfolio[0].stocks.sort((a, b) => a.total - b.total);
  55. }
  56. if (orderBy === 'weight') {
  57. result = portfolio[0].stocks.sort((a, b) => b.total - a.total);
  58. }
  59. result = portfolio[0].stocks;
  60. totalValue = portfolio[0].totalValue;
  61. totalAssets = portfolio[0].totalAssets;
  62. portfolioId = portfolio[0].id;
  63. } else {
  64. await createNewPortfolio();
  65. }
  66. }
  67. async function createNewPortfolio() {
  68. try {
  69. const response = await fetch(`${import.meta.env.VITE_STOCKS_HOST}/api/portfolios`, {
  70. method: 'POST',
  71. headers: {
  72. Authorization: 'Bearer ' + authToken
  73. }
  74. });
  75. if (response.status === 400) {
  76. alert('Bad request. Invalid code.');
  77. return;
  78. }
  79. await fetchPortfolio();
  80. } catch (err) {
  81. console.error('Update failed', err);
  82. }
  83. }
  84. async function updatePortfolio(stocks) {
  85. try {
  86. const response = await fetch(
  87. `${import.meta.env.VITE_STOCKS_HOST}/api/portfolios/${portfolioId}`,
  88. {
  89. method: 'PUT',
  90. headers: {
  91. Authorization: 'Bearer ' + authToken,
  92. 'Content-Type': 'application/json'
  93. },
  94. body: JSON.stringify({ stocks })
  95. }
  96. );
  97. if (response.status === 400) {
  98. alert('Bad request. Invalid code.');
  99. return;
  100. }
  101. await fetchPortfolio();
  102. } catch (err) {
  103. console.error('Update failed', err);
  104. }
  105. }
  106. async function searchStock(code) {
  107. if (!code) return;
  108. try {
  109. const res = await fetch(`${import.meta.env.VITE_STOCKS_HOST}/api/stocks?q=${code}`);
  110. return await res.json();
  111. } catch (err) {
  112. console.error('Search error:', err);
  113. return [];
  114. }
  115. }
  116. async function addSelectedStock(newStock) {
  117. const exists = result.some((stock) => stock.code === newStock.code);
  118. if (exists) return;
  119. result = [
  120. ...result,
  121. {
  122. code: newStock.code,
  123. name: newStock.name,
  124. quantity: 0,
  125. price: newStock.price,
  126. total: 0,
  127. totalPercent: 0
  128. }
  129. ];
  130. closeModal();
  131. await updatePortfolio(result);
  132. }
  133. async function applyChanges() {
  134. try {
  135. await updatePortfolio(result);
  136. hasChanges = false;
  137. } catch (err) {
  138. console.error('Update failed', err);
  139. }
  140. }
  141. function remove(code) {
  142. result = result.filter((stock) => stock.code !== code);
  143. updatePortfolio(result);
  144. }
  145. function formatCurrency(value) {
  146. return value.toLocaleString('en-US', {
  147. style: 'currency',
  148. currency: currency
  149. });
  150. }
  151. function calculatePercentage(part, total) {
  152. return total ? Math.floor((part / total) * 10000) / 100 : 0;
  153. }
  154. function updateStockQuantity(e) {
  155. e.preventDefault();
  156. const form = new FormData(e.target);
  157. const code = form.get('code');
  158. const quantity = parseInt(form.get('quantity')) || 0;
  159. result = result.map((stock) => (stock.code === code ? { ...stock, quantity } : stock));
  160. updatePortfolio(result);
  161. }
  162. function openModal() {
  163. searchStockResult = [];
  164. showModal = true;
  165. }
  166. function closeModal() {
  167. searchStockResult = [];
  168. showModal = false;
  169. }
  170. function updateOrderBy(event) {
  171. orderBy = event.target.value;
  172. fetchPortfolio();
  173. }
  174. function updateCurrency(event) {
  175. currency = event.target.value;
  176. fetchPortfolio();
  177. }
  178. function handleInputChange(event) {
  179. const form = new FormData(event.target.closest('form'));
  180. const code = form.get('code');
  181. const quantity = parseInt(form.get('quantity')) || 0;
  182. result = result.map((stock) => (stock.code === code ? { ...stock, quantity } : stock));
  183. hasChanges = true;
  184. }
  185. </script>
  186. <svelte:head>
  187. <title>Portfolio</title>
  188. <meta name="description" content="Portfolio" />
  189. </svelte:head>
  190. {#if isLoading}
  191. <div in:fade>Loading...</div>
  192. {:else if portfolioId}
  193. <div class="button-container">
  194. <button
  195. class="btn btn-primary btn-sm"
  196. data-toggle="modal"
  197. data-target="#exampleModal"
  198. on:click={openModal}
  199. >
  200. Add
  201. </button>
  202. <select class="form-control order-select" on:change={updateCurrency}>
  203. <option value="brl">BRL</option>
  204. <option value="eur">EURO</option>
  205. <option value="usd" selected>USD</option>
  206. </select>
  207. <select class="form-control order-select" on:change={updateOrderBy}>
  208. <option value="code">Order by Code</option>
  209. <option value="name">Order by Name</option>
  210. <option value="total" selected>Order by Total</option>
  211. <option value="weight">Order by Weight</option>
  212. </select>
  213. </div>
  214. <AddStock
  215. show={showModal}
  216. onClose={closeModal}
  217. onSearch={async (code) => {
  218. const data = await searchStock(code);
  219. if (!data || data.length === 0) {
  220. alert('Stock not found.');
  221. return;
  222. }
  223. searchStockResult = data;
  224. const alreadyInPortfolio = result.some((s) => s.code === data[0]?.code);
  225. if (data.length === 1 && !alreadyInPortfolio) {
  226. await addSelectedStock(data[0]);
  227. closeModal();
  228. }
  229. }}
  230. onAddStock={async (stock) => {
  231. await addSelectedStock(stock);
  232. closeModal();
  233. }}
  234. searchResults={searchStockResult}
  235. />
  236. <div in:fade class="table-container">
  237. <table class="stock-table">
  238. <thead>
  239. <tr>
  240. <th>Total Value</th>
  241. <th>Total Assets</th>
  242. </tr>
  243. </thead>
  244. <tbody>
  245. <tr>
  246. <td class="code">{formatCurrency(totalValue)}</td>
  247. <td class="code">{totalAssets}</td>
  248. </tr>
  249. </tbody>
  250. </table>
  251. </div>
  252. <div in:fade class="table-container">
  253. <table class="stock-table">
  254. <thead>
  255. <tr>
  256. <th>Code</th>
  257. <th>Name</th>
  258. <th>Qty</th>
  259. <th>Price</th>
  260. <th>Total</th>
  261. <th>% of Portfolio</th>
  262. <th scope="col"></th>
  263. </tr>
  264. </thead>
  265. <tbody>
  266. {#each result as stock}
  267. <tr>
  268. <td class="code">{stock.code}</td>
  269. <td class="name">{stock.name}</td>
  270. <td class="qty-edit">
  271. <form id="updateQuantity" on:submit|preventDefault={updateStockQuantity}>
  272. <input type="hidden" name="code" value={stock.code} />
  273. <input
  274. type="number"
  275. class="qty-input"
  276. name="quantity"
  277. value={stock.quantity}
  278. on:input={handleInputChange}
  279. />
  280. </form>
  281. </td>
  282. <td class="price">{formatCurrency(stock.price)}</td>
  283. <td class="total">{formatCurrency(stock.total)}</td>
  284. <td class="percent">{calculatePercentage(stock.total, totalValue)}%</td>
  285. <td>
  286. <button class="remove-btn" on:click={() => remove(stock.code)} title="remove"
  287. ></button>
  288. </td>
  289. </tr>
  290. {/each}
  291. </tbody>
  292. </table>
  293. </div>
  294. {#if hasChanges}
  295. <button class="btn btn-primary apply-changes-btn" on:click={applyChanges}>
  296. Apply Changes
  297. </button>
  298. {/if}
  299. {:else}
  300. <div>No portfolio data available.</div>
  301. {/if}
  302. <style>
  303. .apply-changes-btn {
  304. position: fixed;
  305. bottom: 20px;
  306. right: 20px;
  307. z-index: 100;
  308. }
  309. .table-container {
  310. margin-top: 2rem;
  311. overflow-x: auto;
  312. border-radius: 12px;
  313. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
  314. }
  315. .stock-table {
  316. width: 100%;
  317. border-collapse: collapse;
  318. font-family: system-ui, sans-serif;
  319. background-color: #fff;
  320. border-radius: 12px;
  321. overflow: hidden;
  322. min-width: 600px;
  323. }
  324. th,
  325. td {
  326. padding: 0.5rem 1rem;
  327. text-align: left;
  328. white-space: nowrap;
  329. }
  330. thead {
  331. background-color: #f7f7f7;
  332. border-bottom: 2px solid #e0e0e0;
  333. }
  334. th {
  335. font-weight: 600;
  336. font-size: 0.95rem;
  337. color: #333;
  338. }
  339. tbody tr:nth-child(odd) {
  340. background-color: #fafafa;
  341. }
  342. tbody tr:nth-child(even) {
  343. background-color: #f0f4f8;
  344. }
  345. tbody tr:hover {
  346. background-color: #e1ecf4;
  347. }
  348. td {
  349. font-size: 0.95rem;
  350. color: #555;
  351. border-bottom: 1px solid #eee;
  352. }
  353. .code {
  354. font-weight: 600;
  355. color: #2c3e50;
  356. }
  357. .name {
  358. color: #7f8c8d;
  359. }
  360. .qty-edit {
  361. display: flex;
  362. align-items: center;
  363. gap: 0.5rem;
  364. }
  365. .qty-input {
  366. width: 60px;
  367. padding: 0.4rem 0.5rem;
  368. font-size: 0.9rem;
  369. border: 1px solid #ccc;
  370. border-radius: 6px;
  371. text-align: right;
  372. background-color: #fff;
  373. color: #333;
  374. transition: border-color 0.2s ease;
  375. }
  376. .qty-input:focus {
  377. outline: none;
  378. border-color: #2980b9;
  379. }
  380. .remove-btn {
  381. height: 15px;
  382. width: 15px;
  383. background-color: #e74c3c;
  384. border-radius: 50%;
  385. display: inline-block;
  386. border: 0;
  387. }
  388. .price {
  389. color: #27ae60;
  390. font-weight: bold;
  391. }
  392. .total {
  393. font-weight: 500;
  394. color: #34495e;
  395. }
  396. .percent {
  397. color: #2980b9;
  398. }
  399. @media (max-width: 768px) {
  400. input[type='number'] {
  401. font-size: 0.9rem;
  402. }
  403. }
  404. @media (max-width: 768px) {
  405. .stock-table {
  406. font-size: 0.875rem;
  407. }
  408. th,
  409. td {
  410. padding: 0.75rem 1rem;
  411. }
  412. }
  413. .button-container {
  414. display: flex;
  415. gap: 10px;
  416. margin-bottom: 1rem;
  417. align-items: center;
  418. }
  419. .order-select {
  420. width: 200px;
  421. padding: 0.5rem;
  422. border-radius: 8px;
  423. border: 1px solid #ddd;
  424. font-size: 1rem;
  425. background-color: #f9f9f9;
  426. transition: border-color 0.3s ease;
  427. }
  428. .order-select:focus {
  429. border-color: #2980b9;
  430. outline: none;
  431. }
  432. input[type='number'] {
  433. width: 100%;
  434. font-size: 1rem;
  435. border-radius: 8px;
  436. border: 1px solid #ddd;
  437. margin-bottom: 10px;
  438. background-color: #f9f9f9;
  439. color: #333;
  440. transition: border-color 0.3s ease;
  441. }
  442. input[type='number']:focus {
  443. border-color: #2980b9;
  444. outline: none;
  445. }
  446. .btn {
  447. padding: 0.6rem 1.2rem;
  448. font-size: 1rem;
  449. border-radius: 8px;
  450. border: none;
  451. cursor: pointer;
  452. transition: background-color 0.3s ease;
  453. }
  454. .btn-primary {
  455. background-color: #2980b9;
  456. color: white;
  457. }
  458. .btn-primary:hover {
  459. background-color: #3498db;
  460. }
  461. @keyframes fadeIn {
  462. from {
  463. opacity: 0;
  464. }
  465. to {
  466. opacity: 1;
  467. }
  468. }
  469. </style>