|
|
@@ -1,14 +1,42 @@
|
|
|
<script>
|
|
|
+ import {authentication} from "../store.js";
|
|
|
+ import {onMount} from "svelte";
|
|
|
+ import {fade} from 'svelte/transition';
|
|
|
+
|
|
|
+ let portfolioId = undefined;
|
|
|
let result = [];
|
|
|
+ let totalValue = 0;
|
|
|
+ let totalAssets = 0;
|
|
|
+ let authToken;
|
|
|
+ let isLoading = true;
|
|
|
+ let showModal = false;
|
|
|
+ let searchStockResult = [];
|
|
|
+
|
|
|
+ onMount(() => {
|
|
|
+ const unsubscribe = authentication.subscribe(value => {
|
|
|
+ if (value?.token) {
|
|
|
+ authToken = value.token;
|
|
|
+ fetchPortfolio();
|
|
|
+ }
|
|
|
+ });
|
|
|
|
|
|
- async function getPortfolio(code) {
|
|
|
+ return () => unsubscribe();
|
|
|
+ });
|
|
|
+
|
|
|
+ async function fetchPortfolio() {
|
|
|
try {
|
|
|
- const response = await fetch(import.meta.env.VITE_STOCKS_HOST + '/api/portfolios/' + code, {
|
|
|
- method: 'GET'
|
|
|
- });
|
|
|
+ const response = await fetch(
|
|
|
+ `${import.meta.env.VITE_STOCKS_HOST}/api/portfolios`,
|
|
|
+ {
|
|
|
+ method: 'GET',
|
|
|
+ headers: {
|
|
|
+ Authorization: 'Bearer ' + authToken
|
|
|
+ }
|
|
|
+ }
|
|
|
+ );
|
|
|
|
|
|
if (response.ok) {
|
|
|
- return await response.json();
|
|
|
+ await update(response.json())
|
|
|
} else {
|
|
|
const error = await response.json();
|
|
|
console.error('Failed to find portfolio info:', error);
|
|
|
@@ -17,24 +45,126 @@
|
|
|
} catch (err) {
|
|
|
console.error('Failed to find portfolio info', err);
|
|
|
alert('Failed to find portfolio info');
|
|
|
+ } finally {
|
|
|
+ isLoading = false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- async function search(id) {
|
|
|
- const portfolio = await getPortfolio("066b47a9-46be-487f-bcc4-f35835d0ca02");
|
|
|
+ async function update(response) {
|
|
|
+ const portfolio = await response;
|
|
|
+ if (portfolio?.length > 0) {
|
|
|
+ result = portfolio[0].stocks;
|
|
|
+ totalValue = portfolio[0].totalValue;
|
|
|
+ totalAssets = portfolio[0].totalAssets;
|
|
|
+ portfolioId = portfolio[0].id;
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- console.log(portfolio)
|
|
|
+ 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 (portfolio !== undefined && portfolio.stocks.length !== 0) {
|
|
|
- for (const stock of portfolio.stocks) {
|
|
|
- const stockInfo = stock
|
|
|
- if (stockInfo) {
|
|
|
- result = [...result, stockInfo];
|
|
|
- }
|
|
|
+ if (response.status === 400) {
|
|
|
+ alert("Bad request. Invalid code.");
|
|
|
+ return;
|
|
|
}
|
|
|
+
|
|
|
+ await fetchPortfolio();
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Update failed', err);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function searchStock(code) {
|
|
|
+ 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 handleSubmit(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const code = new FormData(e.target).get("stock_code").toUpperCase();
|
|
|
+
|
|
|
+ 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]);
|
|
|
+ closeOrOpenModal();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
+ }
|
|
|
+ ];
|
|
|
+
|
|
|
+ closeOrOpenModal();
|
|
|
+
|
|
|
+ await updatePortfolio(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ function remove(code) {
|
|
|
+ result = result.filter(stock => stock.code !== code);
|
|
|
+ updatePortfolio(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ function formatCurrency(value) {
|
|
|
+ return value.toLocaleString('en-US', {
|
|
|
+ style: 'currency',
|
|
|
+ currency: 'USD'
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 closeOrOpenModal() {
|
|
|
+ searchStockResult = [];
|
|
|
+ showModal = !showModal;
|
|
|
+ }
|
|
|
</script>
|
|
|
|
|
|
<svelte:head>
|
|
|
@@ -42,99 +172,358 @@
|
|
|
<meta name="description" content="About"/>
|
|
|
</svelte:head>
|
|
|
|
|
|
-<div>
|
|
|
- <div class="card">
|
|
|
- <form on:submit={search}>
|
|
|
- <input type="text" name="stock" placeholder="Enter a portfolio id" class="input-field"/>
|
|
|
- <button type="submit" class="search-button">Add</button>
|
|
|
- </form>
|
|
|
- </div>
|
|
|
+{#if isLoading}
|
|
|
+ <div in:fade>Loading...</div>
|
|
|
+{:else if result.length !== 0}
|
|
|
|
|
|
- {#if result.length !== 0}
|
|
|
- <div class="result">
|
|
|
- {#each result as stock}
|
|
|
- <div class="card card2">
|
|
|
- <div class="stock-info">
|
|
|
- <div class="stock-code">{stock.code}</div>
|
|
|
- <div class="stock-name">{stock.name}</div>
|
|
|
+ <button class="btn btn-primary" data-toggle="modal" data-target="#exampleModal" on:click={closeOrOpenModal}>Add
|
|
|
+ </button>
|
|
|
+
|
|
|
+ {#if showModal}
|
|
|
+ <div class="modal-container">
|
|
|
+ <div class="modal-content">
|
|
|
+ <form on:submit|preventDefault={handleSubmit}>
|
|
|
+ <div class="row">
|
|
|
+ <div class="col">
|
|
|
+ <input type="text" class="form-control" placeholder="stock code or name"
|
|
|
+ name="stock_code"
|
|
|
+ oninput="this.value = this.value.toUpperCase()"
|
|
|
+ autocomplete="off" autofocus>
|
|
|
+ </div>
|
|
|
+ <div class="col">
|
|
|
+ <input type="reset" value="cancel" class="btn btn-danger" on:click={closeOrOpenModal}/>
|
|
|
+ <input type="submit" value="search" class="btn btn-primary"/>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class="stock-details">
|
|
|
- <div class="stock-currency">{stock.currency || "USD"}</div>
|
|
|
- <div class="stock-price">{stock.price}</div>
|
|
|
+ </form>
|
|
|
+
|
|
|
+ {#if searchStockResult.length > 0}
|
|
|
+ <div class="modal-result">
|
|
|
+ <div class="card" style="width: 100%;">
|
|
|
+ <ul class="list-group list-group-flush">
|
|
|
+ {#each searchStockResult as result}
|
|
|
+ <li class="list-group-item d-flex justify-content-between align-items-center"
|
|
|
+ on:click={addSelectedStock(result)}>
|
|
|
+ ({result.code}) {result.name}
|
|
|
+ <button class="btn btn-primary btn-sm">+</button>
|
|
|
+ </li>
|
|
|
+ {/each}
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- {/each}
|
|
|
+ {/if}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
{/if}
|
|
|
-</div>
|
|
|
+
|
|
|
+ <div in:fade class="table-container">
|
|
|
+ <table class="stock-table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>Total Value</th>
|
|
|
+ <th>Total Assets</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ <tr>
|
|
|
+ <td class="code">{formatCurrency(totalValue)}</td>
|
|
|
+ <td class="code">{totalAssets}</td>
|
|
|
+ </tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div in:fade class="table-container">
|
|
|
+ <table class="stock-table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>Code</th>
|
|
|
+ <th>Name</th>
|
|
|
+ <th>Qty</th>
|
|
|
+ <th>Price</th>
|
|
|
+ <th>Total</th>
|
|
|
+ <th>% of Portfolio</th>
|
|
|
+ <th scope="col"></th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {#each result as stock}
|
|
|
+ <tr>
|
|
|
+ <td class="code">{stock.code}</td>
|
|
|
+ <td class="name">{stock.name}</td>
|
|
|
+ <td class="qty-edit">
|
|
|
+ <form id="updateQuantity" on:submit|preventDefault={updateStockQuantity}>
|
|
|
+ <input type="hidden" name="code" value="{stock.code}"/>
|
|
|
+ <input type="number" class="qty-input" name="quantity" value="{stock.quantity}"/>
|
|
|
+ </form>
|
|
|
+ </td>
|
|
|
+ <td class="price">{formatCurrency(stock.price)}</td>
|
|
|
+ <td class="total">{formatCurrency(stock.total)}</td>
|
|
|
+ <td class="percent">{calculatePercentage(stock.total, totalValue)}%</td>
|
|
|
+ <td>
|
|
|
+ <button class="remove-btn" on:click={() => remove(stock.code)} title="remove"></button>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ {/each}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+{:else}
|
|
|
+ <div>No portfolio data available.</div>
|
|
|
+{/if}
|
|
|
|
|
|
<style>
|
|
|
- .result {
|
|
|
- margin-top: 1rem;
|
|
|
- display: flex;
|
|
|
- flex-wrap: wrap;
|
|
|
- gap: 2rem;
|
|
|
+ .table-container {
|
|
|
+ margin-top: 2rem;
|
|
|
+ overflow-x: auto;
|
|
|
+ border-radius: 12px;
|
|
|
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
|
|
+ }
|
|
|
+
|
|
|
+ .stock-table {
|
|
|
+ width: 100%;
|
|
|
+ border-collapse: collapse;
|
|
|
+ font-family: system-ui, sans-serif;
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 12px;
|
|
|
+ overflow: hidden;
|
|
|
+ min-width: 600px;
|
|
|
}
|
|
|
|
|
|
- .card {
|
|
|
- flex: 1 1 300px;
|
|
|
+ th, td {
|
|
|
+ padding: 1rem 1.25rem;
|
|
|
+ text-align: left;
|
|
|
+ white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
- .card2 {
|
|
|
+ thead {
|
|
|
+ background-color: #f7f7f7;
|
|
|
+ border-bottom: 2px solid #e0e0e0;
|
|
|
+ }
|
|
|
+
|
|
|
+ th {
|
|
|
+ font-weight: 600;
|
|
|
+ font-size: 0.95rem;
|
|
|
+ color: #333;
|
|
|
+ }
|
|
|
+
|
|
|
+ tbody tr:nth-child(odd) {
|
|
|
background-color: #fafafa;
|
|
|
- border: 1px solid #eee;
|
|
|
- border-radius: 12px;
|
|
|
- max-width: 267px;
|
|
|
}
|
|
|
|
|
|
- .card:hover {
|
|
|
- transform: translateY(-10px);
|
|
|
+ tbody tr:nth-child(even) {
|
|
|
+ background-color: #f0f4f8;
|
|
|
+ }
|
|
|
+
|
|
|
+ tbody tr:hover {
|
|
|
+ background-color: #e1ecf4;
|
|
|
}
|
|
|
|
|
|
- .stock-info {
|
|
|
+ td {
|
|
|
+ font-size: 0.95rem;
|
|
|
+ color: #555;
|
|
|
+ border-bottom: 1px solid #eee;
|
|
|
+ }
|
|
|
+
|
|
|
+ .code {
|
|
|
+ font-weight: 600;
|
|
|
+ color: #2c3e50;
|
|
|
+ }
|
|
|
+
|
|
|
+ .name {
|
|
|
+ color: #7f8c8d;
|
|
|
+ }
|
|
|
+
|
|
|
+ .qty-edit {
|
|
|
display: flex;
|
|
|
- justify-content: space-between;
|
|
|
align-items: center;
|
|
|
- margin-bottom: 10px;
|
|
|
+ gap: 0.5rem;
|
|
|
}
|
|
|
|
|
|
- .stock-code {
|
|
|
- font-size: 1.5rem;
|
|
|
- font-weight: bold;
|
|
|
+ .qty-input {
|
|
|
+ width: 60px;
|
|
|
+ padding: 0.4rem 0.5rem;
|
|
|
+ font-size: 0.9rem;
|
|
|
+ border: 1px solid #ccc;
|
|
|
+ border-radius: 6px;
|
|
|
+ text-align: right;
|
|
|
+ background-color: #fff;
|
|
|
color: #333;
|
|
|
+ transition: border-color 0.2s ease;
|
|
|
}
|
|
|
|
|
|
- .stock-name {
|
|
|
- font-size: 1.1rem;
|
|
|
- font-weight: normal;
|
|
|
- color: #555;
|
|
|
- text-align: right;
|
|
|
+ .qty-input:focus {
|
|
|
+ outline: none;
|
|
|
+ border-color: #2980b9;
|
|
|
+ }
|
|
|
+
|
|
|
+ .remove-btn {
|
|
|
+ height: 15px;
|
|
|
+ width: 15px;
|
|
|
+ background-color: #e74c3c;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: inline-block;
|
|
|
+ border: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .price {
|
|
|
+ color: #27ae60;
|
|
|
+ font-weight: bold;
|
|
|
+ }
|
|
|
+
|
|
|
+ .total {
|
|
|
+ font-weight: 500;
|
|
|
+ color: #34495e;
|
|
|
+ }
|
|
|
+
|
|
|
+ .percent {
|
|
|
+ color: #2980b9;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Responsive design */
|
|
|
+ @media (max-width: 768px) {
|
|
|
+ .modal-content {
|
|
|
+ width: 90%;
|
|
|
+ padding: 1.5rem;
|
|
|
+ }
|
|
|
+
|
|
|
+ input[type="text"],
|
|
|
+ input[type="number"] {
|
|
|
+ font-size: 0.9rem;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-result {
|
|
|
+ max-height: 200px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .list-group-item {
|
|
|
+ font-size: 0.9rem;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @media (max-width: 768px) {
|
|
|
+ .stock-table {
|
|
|
+ font-size: 0.875rem;
|
|
|
+ }
|
|
|
+
|
|
|
+ th, td {
|
|
|
+ padding: 0.75rem 1rem;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- .stock-info:after {
|
|
|
- content: '';
|
|
|
- display: block;
|
|
|
+ /* Modal */
|
|
|
+ .modal-container {
|
|
|
+ position: fixed;
|
|
|
+ top: 20%;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ z-index: 9999;
|
|
|
+ animation: fadeIn 0.3s ease;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Modal content styling */
|
|
|
+ .modal-content {
|
|
|
+ background-color: #fff;
|
|
|
+ color: #333;
|
|
|
+ width: 450px;
|
|
|
+ height: 130px;
|
|
|
+ max-width: 500px;
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 2rem;
|
|
|
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
|
|
+ transition: transform 0.3s ease-in-out;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Form input fields styling */
|
|
|
+ input[type="text"],
|
|
|
+ input[type="number"],
|
|
|
+ input[type="reset"],
|
|
|
+ input[type="submit"] {
|
|
|
+ font-size: 1rem;
|
|
|
+ padding: 10px;
|
|
|
+ margin: 2px;
|
|
|
+ border-radius: 8px;
|
|
|
+ border: 1px solid #ddd;
|
|
|
width: 100%;
|
|
|
- height: 1px;
|
|
|
- background-color: #ddd;
|
|
|
- margin-top: 10px;
|
|
|
+ transition: border-color 0.3s ease;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Button specific styles */
|
|
|
+ input[type="submit"] {
|
|
|
+ background-color: #3498db;
|
|
|
+ color: white;
|
|
|
+ cursor: pointer;
|
|
|
+ border: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ input[type="submit"]:hover {
|
|
|
+ background-color: #2980b9;
|
|
|
+ }
|
|
|
+
|
|
|
+ input[type="reset"] {
|
|
|
+ background-color: #e74c3c;
|
|
|
+ color: white;
|
|
|
+ border: none;
|
|
|
}
|
|
|
|
|
|
- .stock-details {
|
|
|
+ input[type="reset"]:hover {
|
|
|
+ background-color: #c0392b;
|
|
|
+ }
|
|
|
+
|
|
|
+ input[type="text"]:focus,
|
|
|
+ input[type="number"]:focus {
|
|
|
+ outline: none;
|
|
|
+ border-color: #2980b9;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Results section styling */
|
|
|
+ .modal-result {
|
|
|
+ max-width: 80%;
|
|
|
+ max-height: 250px;
|
|
|
+ transition: opacity 0.3s ease;
|
|
|
+ }
|
|
|
+
|
|
|
+ .list-group-item {
|
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
|
align-items: center;
|
|
|
- margin-top: 15px;
|
|
|
+ padding: 10px;
|
|
|
+ border: 1px solid #ddd;
|
|
|
+ margin-bottom: 0.5rem;
|
|
|
+ border-radius: 8px;
|
|
|
+ background-color: #f9f9f9;
|
|
|
+ transition: background-color 0.3s ease;
|
|
|
}
|
|
|
|
|
|
- .stock-currency {
|
|
|
- font-size: 1rem;
|
|
|
- color: #888;
|
|
|
+ .list-group-item:hover {
|
|
|
+ background-color: #f1f1f1;
|
|
|
}
|
|
|
|
|
|
- .stock-price {
|
|
|
- font-size: 1.5rem;
|
|
|
- font-weight: bold;
|
|
|
- color: #27ae60;
|
|
|
+ .list-group-item .btn-primary {
|
|
|
+ background-color: #2980b9;
|
|
|
+ color: white;
|
|
|
+ border-radius: 50%;
|
|
|
+ height: 30px;
|
|
|
+ width: 30px;
|
|
|
+ padding: 0;
|
|
|
+ border: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ .list-group-item .btn-primary:hover {
|
|
|
+ background-color: #3498db;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Modal transition */
|
|
|
+ @keyframes fadeIn {
|
|
|
+ from {
|
|
|
+ opacity: 0;
|
|
|
+ }
|
|
|
+ to {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
}
|
|
|
-</style>
|
|
|
+
|
|
|
+</style>
|