Thursday, 28 May 2026

Web Tutorial: VueJS Financial Projector (Part 3/3)

At long last, we get to the exciting part! No more CRUD functions - now it's purely display.

Add this to the data. monthItems is an array of data from January to December. monthItemsTemplate is an array that shows us what that data is supposed to look like. The idea here is that the display will refresh when monthItems changes... but I don't want it to render constantly when I recalculate, so I have a temporary array to hold the new completely recalculated array in until I'm ready to replace monthItems.
data: {
    currentIndex: 0,
    errors: {
        name: "",
        amount: "",
    },
    items:[
        {
            name: "",
            month: 0,
            amount: 0
        }
    ],
    monthItems: [],
    monthItemsTemplate: [

    ],

    months: [
        "All", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
    ]
},


So right now, this is it. The table looks like this. The first element at index 0 is an object that's just a placeholder. The other rows correspond with the month names - index 1 for "Jan", index 2 for "Feb", etc.
monthItems: [],
monthItemsTemplate: [
    { monthName: "", itemsIn:[], itemsInTotal:"", itemsOut:[], itemsOutTotal:"", cumulative: "" },
    { monthName: "Jan", itemsIn:[], itemsInTotal:0, itemsOut:[], itemsOutTotal:0, cumulative: 0 },
    { monthName: "Feb", itemsIn:[], itemsInTotal:0, itemsOut:[], itemsOutTotal:0, cumulative: 0 },
    { monthName: "Mar", itemsIn:[], itemsInTotal:0, itemsOut:[], itemsOutTotal:0, cumulative: 0 },
    { monthName: "Apr", itemsIn:[], itemsInTotal:0, itemsOut:[], itemsOutTotal:0, cumulative: 0 },
    { monthName: "May", itemsIn:[], itemsInTotal:0, itemsOut:[], itemsOutTotal:0, cumulative: 0 },
    { monthName: "Jun", itemsIn:[], itemsInTotal:0, itemsOut:[], itemsOutTotal:0, cumulative: 0 },
    { monthName: "Jul", itemsIn:[], itemsInTotal:0, itemsOut:[], itemsOutTotal:0, cumulative: 0 },
    { monthName: "Aug", itemsIn:[], itemsInTotal:0, itemsOut:[], itemsOutTotal:0, cumulative: 0 },
    { monthName: "Sep", itemsIn:[], itemsInTotal:0, itemsOut:[], itemsOutTotal:0, cumulative: 0 },
    { monthName: "Oct", itemsIn:[], itemsInTotal:0, itemsOut:[], itemsOutTotal:0, cumulative: 0 },
    { monthName: "Nov", itemsIn:[], itemsInTotal:0, itemsOut:[], itemsOutTotal:0, cumulative: 0 },
    { monthName: "Dec", itemsIn:[], itemsInTotal:0, itemsOut:[], itemsOutTotal:0, cumulative: 0 }

],


After this, we need a new method, sortItemOrder(). This populates the monthItems array with the correct data. Because elements added, updated or removed in items will trigger a recalculation of cumulative values. The idea for monthItems is that each month, from January to December, will show totals after calculating incoming and outgoing funds.
    setCurrentIndex: function(val) {
        this.currentIndex = val;
    },
    sortItemOrder: function() {

    }

}


We declare a temporary array, tempMonthItems. As its value, we use a copy of monthItemsTemplate using the structuredClone() function. At the end of this method, we're going to replace monthItems with tempMonthItems after processing it. (For more about the structuredClone() function, follow this link.)
sortItemOrder: function() {
    let tempMonthItems = structuredClone(this.monthItemsTemplate);

    this.monthItems = structuredClone(tempMonthItems);

}


As part of that process, we're going to fix the elements in tempMonthItems corresponding to months 1 to 12, i.e, January to December. Index 0 is the placeholder element.
sortItemOrder: function() {
    let tempMonthItems = structuredClone(this.monthItemsTemplate);

    for (let i = 1; i <= 12; i++) {

    }


    this.monthItems = structuredClone(tempMonthItems);
}


We populate itemsIn and itemsOut. itemsIn in each element of tempMonthItems, is the array of every element in items where month is "All" or the current month, and amount is more than 0. itemsOut in each element of tempMonthItems, is the array of every element in items where month is "All" or the current month, and amount is more than 0. To get this data, we use the filter() method.
sortItemOrder: function() {
    let tempMonthItems = structuredClone(this.monthItemsTemplate);

    for (let i = 1; i <= 12; i++) {
        tempMonthItems[i].itemsIn = this.items.filter((item) => {return item.amount > 0 && [0, i].indexOf(item.month) != -1});
        tempMonthItems[i].itemsOut = this.items.filter((item) => {return item.amount < 0 && [0, i].indexOf(item.month) != -1});
    }

    this.monthItems = structuredClone(tempMonthItems);
}


Then we want the sum of all items in itemsIn and itemsOut, and assign those values to the itemsInTotal and itemsOutTotal properties, using the reduce() method. (For more about the reduce() method, follow this link.)
sortItemOrder: function() {
    let tempMonthItems = structuredClone(this.monthItemsTemplate);

    for (let i = 1; i <= 12; i++) {
        tempMonthItems[i].itemsIn = this.items.filter((item) => {return item.amount > 0 && [0, i].indexOf(item.month) != -1});
        tempMonthItems[i].itemsOut = this.items.filter((item) => {return item.amount < 0 && [0, i].indexOf(item.month) != -1});

        tempMonthItems[i].itemsInTotal = tempMonthItems[i].itemsIn.reduce((sum, item) => sum + item.amount, 0);
        tempMonthItems[i].itemsOutTotal = tempMonthItems[i].itemsOut.reduce((sum, item) => sum + item.amount, 0);

    }

    this.monthItems = structuredClone(tempMonthItems);
}


The cumulative property is 0 if we're looking at January because we haven't accumulated anything yet at the start of the year. Thus, if i is 1 or less, cumulative is 0.
sortItemOrder: function() {
    let tempMonthItems = structuredClone(this.monthItemsTemplate);

    for (let i = 1; i <= 12; i++) {
        tempMonthItems[i].itemsIn = this.items.filter((item) => {return item.amount > 0 && [0, i].indexOf(item.month) != -1});
        tempMonthItems[i].itemsOut = this.items.filter((item) => {return item.amount < 0 && [0, i].indexOf(item.month) != -1});

        tempMonthItems[i].itemsInTotal = tempMonthItems[i].itemsIn.reduce((sum, item) => sum + item.amount, 0);
        tempMonthItems[i].itemsOutTotal = tempMonthItems[i].itemsOut.reduce((sum, item) => sum + item.amount, 0);

        tempMonthItems[i].cumulative = (i > 1 ? : 0);
    }

    this.monthItems = structuredClone(tempMonthItems);
}


If we're at February or later, we take the remainding funds of the current month (itemsInTotal + itemsOutTotal) and add the cumulative property of the previous month, to derive the current month's cumulative value.
sortItemOrder: function() {
    let tempMonthItems = structuredClone(this.monthItemsTemplate);

    for (let i = 1; i <= 12; i++) {
        tempMonthItems[i].itemsIn = this.items.filter((item) => {return item.amount > 0 && [0, i].indexOf(item.month) != -1});
        tempMonthItems[i].itemsOut = this.items.filter((item) => {return item.amount < 0 && [0, i].indexOf(item.month) != -1});

        tempMonthItems[i].itemsInTotal = tempMonthItems[i].itemsIn.reduce((sum, item) => sum + item.amount, 0);
        tempMonthItems[i].itemsOutTotal = tempMonthItems[i].itemsOut.reduce((sum, item) => sum + item.amount, 0);

        tempMonthItems[i].cumulative = (i > 1 ? tempMonthItems[i].itemsInTotal + tempMonthItems[i].itemsOutTotal + tempMonthItems[i - 1].cumulative : 0);
    }

    this.monthItems = structuredClone(tempMonthItems);
}


With that done, we make sure to run sortItemOrder() at the end of addUpdateItem() and removeItem().
    this.setCurrentIndex(0);
    this.sortItemOrder();
},
removeItem: function(index) {
    this.items.splice(index, 1);
    this.setCurrentIndex(0);
    this.sortItemOrder();
},
setCurrentIndex: function(val) {
    this.currentIndex = val;
},


Now, for the HTML!
There, in pnlFinancialProjection, have a table. These are the headers.
<div id="pnlFinancialProjection" class="panel">
    <table>
        <tr class="header">
            <td width="20%">MONTH</td>
            <td width="20%" class="numeric">IN</td>
            <td width="20%" class="numeric">OUT</td>
            <td width="20%" class="numeric">REMAINING</td>
            <td width="20%" class="numeric">CUMULATIVE</td>
        </tr>
    </table>

</div>


And here's your table taking shape...


Then we render rows for every element in monthItems other than the one at index 0.
<table>
        <tr class="header">
            <td width="20%">MONTH</td>
            <td width="20%" class="numeric">IN</td>
            <td width="20%" class="numeric">OUT</td>
            <td width="20%" class="numeric">REMAINING</td>
            <td width="20%" class="numeric">CUMULATIVE</td>
        </tr>

        <tr v-for="(monthItem, monthItemIndex) in monthItems" v-bind:key="monthItemIndex" v-if="monthItemIndex > 0">

        </tr>

</table>


We put in the month name, itemsInTotal, itemsOutTotal, and in the case of the REMAINING column, we calculate it on the spot. If the result is positive, we style it using inText, otherwise we use outText. In both cases, we also use numeric. We do the same for cumulative.
<tr v-for="(monthItem, monthItemIndex) in monthItems" v-bind:key="monthItemIndex" v-if="monthItemIndex > 0">
    <td>{{ monthItem.monthName }}</td>
    <td class="numeric inText">{{ monthItem.itemsInTotal }}</td>
    <td class="numeric outText">{{ monthItem.itemsOutTotal }}</td>
    <td v-bind:class=" monthItem.itemsInTotal + monthItem.itemsOutTotal > 0 ? 'numeric inText' : 'numeric outText'">{{ monthItem.itemsInTotal + monthItem.itemsOutTotal }}</td>
    <td v-bind:class=" monthItem.cumulative > 0 ? 'numeric inText' : 'numeric outText'">{{ monthItem.cumulative }}</td>

</tr>



Let's test this app!
Add the item "Salary" for all months. I'm just going to put SGD 6,000 here.


Then we'll balance it out with "Expenses", which is an outgoing item for all months. We set it at SGD 1,000. Look at the financial projection now.


Then we have "Income Tax", which is to be paid in May.


Then I declare "Annual Bonus" in December, an incoming item at another SGD 6,000.

And let's say I pay "AIA Insurance" in September. Now in the financial projection, you can see that the REMAINING column is starting to show red.

Here, I add "Womb Tax" for all months, which is my quaint codeword for money I give my mother.


And "Investments" for all months. Now you can see , more of the REMAINING column has turned red. The CUMULATIVE column adjusts automatically as well.


Right on the money,
T___T

Monday, 25 May 2026

Web Tutorial: VueJS Financial Projector (Part 2/3)

The next part is to display items in a table, so you can see what items you added.

In the HTML, inside the pnlItems div, add a table. The header will be styled using the CSS class header, while the AMOUNT column will be styled using the CSS class numeric.
<div id="pnlItems" class="panel">
    <table>
        <tr class="header">

            <td width="20%">MONTH</td>
            <td width="40%">NAME</td>
            <td width="20%" class="numeric">AMOUNT</td>
            <td width="10%"></td>
            <td width="10%"></td>
        </tr>
    </table>
</div>


header will be in bold, and numeric means that text is aligned right. That's really all there is to it.
.panel
{
    float: left;
    padding: 10px;
    border-radius: 20px;
    outline: 1px solid rgba(255, 150, 0, 0.5);
    margin-right: 10px;
    margin-bottom: 10px;
}

.numeric
{
    text-align: right;
}

.header
{
    font-weight: bold;
}


label
{
    width: 5em;
    font-size: 0.5em;
    float: left;
}


So we've got the beginnings of a table.


Now let's add content. We want the rows to render for as many elements there are in items. We also want to set key because it's a repeated HTML element we're creating.
<div id="pnlItems" class="panel">
    <table>
        <tr class="header">
            <td width="20%">MONTH</td>
            <td width="40%">NAME</td>
            <td width="20%" class="numeric">AMOUNT</td>
            <td width="10%"></td>
            <td width="10%"></td>
        </tr>

        <tr v-for="(i, index) in items" v-bind:key="index">
            <td></td>
            <td></td>
            <td></td>
            <td></td>
            <td></td>
        </tr>
    </table>
</div>


In here, we add the month, the name and amount. Note that the amount column is styled using the numeric CSS class.
<tr v-for="(i, index) in items" v-bind:key="index">
    <td>{{ months[i.month] }}</td>
    <td>{{ i.name }}</td>
    <td class="numeric">{{ i.amount }}</td>
    <td></td>
    <td></td>
</tr>


Now we can test this. When you refresh, the first item in items appears.


Now add something. Here I call it "Investment Dividend" and say it's incoming of SGD 1,500. Hey, a guy can dream.


Click the ADD button and you see it appears. So far so good.


Now add something else, an outgoing amount. Wifey's birthday isn't in March and I wish I spent only a thousand, but this is just an example.


See? Some things to correct.
- The amount appears negative. Would be nice if we could color code this.
- It appears in order of entry, which is fine until you have like 50 items.
- We also want to not show the first item.


We start off by adding these CSS classes, inText and outText. So incoming money is marked in green, and outgoing money in red.
label
{
    width: 5em;
    font-size: 0.5em;
    float: left;
}

.inText
{
    color: rgb(0, 200, 0);
}

.outText
{
    color: rgb(200, 0, 0);
}


input[type=text], input[type=number], select
{
    width: 10em;
    padding: 0em;
}


Then we change the class. Instead of just styling using numeric, we use a combination of numeric and inText or outText, depending on whether the amount is positive.
<tr v-for="(i, index) in items" v-bind:key="index">
    <td>{{ months[i.month] }}</td>
    <td>{{ i.name }}</td>
    <td v-bind:class="i.amount > 0 ? 'numeric inText' : 'numeric outText'">{{ i.amount }}</td>
    <td></td>
    <td></td>
</tr>


Now in the HTML, we add a conditional. This means the HTML element renders only if index is greater than 0.
<tr v-for="(i, index) in items" v-bind:key="index" v-if="index > 0">


If you retry everything, you should see that incoming and outgoing amounts are colored differently, and the first row no longer appears. But soon, we'll be doing something bigger.

In computed, add the method sortedItems(). This actually returns the sorted view of the items array.
computed: {
    sortedItems: function() {
        return this.items

    }
},


Here, we use the map() method to iterate through items and return the index and the element as a new object. This serves to preserve both the index, index and the element, item.
computed: {
    sortedItems: function() {
        return this.items
        .map((item, index) => ({ item, index }))
    }
},


And we continue by chaining on a sort() method, sorting by the month property of item. This works because we need the index... but sorting items and adding or removing from it, might change index.
computed: {
    sortedItems: function() {
        return this.items
        .map((item, index) => ({ item, index }))
        .sort((a, b) => a.item.month - b.item.month);
    }
},


Now we'll need to change this. Instead of iterating through items, we iterate through sortedItems and we change all mentions of i to si. Since each element of sortedItems is made of index and item, if we want to refer to the element's properties, we have to refer to it as item.
<tr v-for="si in sortedItems" v-bind:key="si.index" v-if="si.index > 0">
    <td>{{ months[si.item.month] }}</td>
    <td>{{ si.item.name }}</td>
    <td v-bind:class=" si.item.amount > 0 ? 'numeric inText' : 'numeric outText'">{{ si.item.amount }}</td>
    <td></td>
    <td></td>
</tr>


We'll then add two buttons. One is an UPDATE button and will run the setCurrentIndex() method, passing in index as an argument. The other is a DELETE button that runs the removeItem() method, also passing in index as an argument.
<tr v-for="si in sortedItems" v-bind:key="si.index" v-if="si.index > 0">
    <td>{{ months[si.item.month] }}</td>
    <td>{{ si.item.name }}</td>
    <td v-bind:class=" si.item.amount > 0 ? 'numeric inText' : 'numeric outText'">{{ si.item.amount }}</td>
    <td><input type="button" value="UPDATE" @click="setCurrentIndex(si.index)" /></td>
    <td><input type="button" value="DELETE" @click="removeItem(si.index)" /></td>
</tr>


There be buttons! And you may notice, if you enter a June item first and then a February item, they are now sorted properly by month regardless of what order they were entered in. The first default element from items is no longer there, filtered out by the conditional.


Create these two methods. removeItem() has a parameter, index. setCurrentIndex() has a parameter as well, val.
methods: {
    addUpdateItem: function() {
        this.errors.name = "";
        this.errors.amount = "";

        let nameValue = this.$refs.itemName.value.trim();
        let amountValue = parseFloat(this.$refs.itemAmount.value.trim());
        let monthValue = parseInt(this.$refs.itemMonth.value);
        let errors = 0;

        if (nameValue == "") { this.errors.name = "Required"; errors++; }
        if (amountValue <= 0 || isNaN(amountValue)) { this.errors.amount = "Must be positive"; errors++; }
        if (errors > 0) return;

        if (this.$refs.itemTypeOut.checked) amountValue = amountValue * -1;

        if (this.currentIndex == 0) {
            this.items.push({
                name: nameValue,
                month: monthValue,
                amount: amountValue
            });
        }
    },
    removeItem: function(index) {

    },
    setCurrentIndex: function(val) {

    }

}


setCurrentIndex() is straightforward - simply assign the value of val to currentIndex.
removeItem: function(index) {

},
setCurrentIndex: function(val) {
    this.currentIndex = val;
}


removeItem() uses the splice() method to remove the element at position index in items, then resets currentIndex to 0 (just in case it was something else).
removeItem: function(index) {
    this.items.splice(index, 1);
    this.setCurrentIndex(0);

},
setCurrentIndex: function(val) {
    this.currentIndex = val;
}


Now, let's test this. Add this item - "Bonus A" at SGD 5,000 in May. Then add "Bonus B" at SGD 15,000 in June. Click on UPDATE for "Bonus B". setCurrentIndex() should ensure that Bonus B's details appear in the upper right! Also note that the button now says "UPDATE"! That's because currentIndex is no longer 0.


Click on DELETE for "Bonus A". The item vanishes, and setCurrentIndex() changes currentIndex back to 0, so the upper right panel changes as well.


Update the addUpdateItem() method. Before, we only handled the case for currentIndex being 0. Now if currentIndex is not 0, this means it's an update. And we update the values accordingly. The values, of course, have already been validated.
addUpdateItem: function() {
    this.errors.name = "";
    this.errors.amount = "";

    let nameValue = this.$refs.itemName.value.trim();
    let amountValue = parseFloat(this.$refs.itemAmount.value.trim());
    let monthValue = parseInt(this.$refs.itemMonth.value);
    let errors = 0;

    if (nameValue == "") { this.errors.name = "Required"; errors++; }
    if (amountValue <= 0 || isNaN(amountValue)) { this.errors.amount = "Must be positive"; errors++; }
    if (errors > 0) return;

    if (this.$refs.itemTypeOut.checked) amountValue = amountValue * -1;

    if (this.currentIndex == 0) {
        this.items.push({
            name: nameValue,
            month: monthValue,
            amount: amountValue
        });
    } else {
        this.items[this.currentIndex].name = nameValue;
        this.items[this.currentIndex].amount = amountValue;
        this.items[this.currentIndex].month = monthValue;
    }


    this.setCurrentIndex(0);
},


In pnlItem, we add another button. It says "NEW", and when you click on it, it sets currentIndex back to 0. And it renders only if currentIndex is greater than 0.
<p>
    <label for="itemAmount">Amount</label>
    <br />
    <input ref="itemAmount" id="itemAmount" type="number" v-bind:value="Math.abs(items[currentIndex].amount)">
    <span class="error">{{ errors.amount }}</span>
</p>
<input type="button" value="NEW" @click="setCurrentIndex(0)" v-if="currentIndex > 0" />
<input type="button" v-bind:value="currentIndex == 0 ? 'ADD' : 'UPDATE'" @click="addUpdateItem" />


Again, add these items - "Bonus A" at SGD 5,000 in May. Then add "Bonus B" at SGD 15,000 in June. Click UPDATE for Bonus A. The NEW button appears!



Ignore that button for now. Set the amount to 8000 and click UPDATE (the one in the top right corner). It should reflect the new value in the list of items below.


Now click the UPDATE button on "Bonus B". See the NEW button appear again? What happens when you click it? That's right - it should set currentIndex to 0 and give you the "New Item" view.


Next

Showing the Financial Projection.The next part is to display items in a table, so you can see what items you added.

In the HTML, inside the pnlItems div, add a table. The header will be styled using the CSS class header, while the AMOUNT column will be styled using the CSS class numeric.
<div id="pnlItems" class="panel">
    <table>
        <tr class="header">

            <td width="20%">MONTH</td>
            <td width="40%">NAME</td>
            <td width="20%" class="numeric">AMOUNT</td>
            <td width="10%"></td>
            <td width="10%"></td>
        </tr>
    </table>
</div>


header will be in bold, and numeric means that text is aligned right. That's really all there is to it.
.panel
{
    float: left;
    padding: 10px;
    border-radius: 20px;
    outline: 1px solid rgba(255, 150, 0, 0.5);
    margin-right: 10px;
    margin-bottom: 10px;
}

.numeric
{
    text-align: right;
}

.header
{
    font-weight: bold;
}


label
{
    width: 5em;
    font-size: 0.5em;
    float: left;
}


So we've got the beginnings of a table.


Now let's add content. We want the rows to render for as many elements there are in items. We also want to set key because it's a repeated HTML element we're creating.
<div id="pnlItems" class="panel">
    <table>
        <tr class="header">
            <td width="20%">MONTH</td>
            <td width="40%">NAME</td>
            <td width="20%" class="numeric">AMOUNT</td>
            <td width="10%"></td>
            <td width="10%"></td>
        </tr>

        <tr v-for="(i, index) in items" v-bind:key="index">
            <td></td>
            <td></td>
            <td></td>
            <td></td>
            <td></td>
        </tr>
    </table>
</div>


In here, we add the month, the name and amount. Note that the amount column is styled using the numeric CSS class.
<tr v-for="(i, index) in items" v-bind:key="index">
    <td>{{ months[i.month] }}</td>
    <td>{{ i.name }}</td>
    <td class="numeric">{{ i.amount }}</td>
    <td></td>
    <td></td>
</tr>


Now we can test this. When you refresh, the first item in items appears.


Now add something. Here I call it "Investment Dividend" and say it's incoming of SGD 1,500. Hey, a guy can dream.


Click the ADD button and you see it appears. So far so good.


Now add something else, an outgoing amount. Wifey's birthday isn't in March and I wish I spent only a thousand, but this is just an example.


See? Some things to correct.
- The amount appears negative. Would be nice if we could color code this.
- It appears in order of entry, which is fine until you have like 50 items.
- We also want to not show the first item.


We start off by adding these CSS classes, inText and outText. So incoming money is marked in green, and outgoing money in red.
label
{
    width: 5em;
    font-size: 0.5em;
    float: left;
}

.inText
{
    color: rgb(0, 200, 0);
}

.outText
{
    color: rgb(200, 0, 0);
}


input[type=text], input[type=number], select
{
    width: 10em;
    padding: 0em;
}


Then we change the class. Instead of just styling using numeric, we use a combination of numeric and inText or outText, depending on whether the amount is positive.
<tr v-for="(i, index) in items" v-bind:key="index">
    <td>{{ months[i.month] }}</td>
    <td>{{ i.name }}</td>
    <td v-bind:class="i.amount > 0 ? 'numeric inText' : 'numeric outText'">{{ i.amount }}</td>
    <td></td>
    <td></td>
</tr>


Now in the HTML, we add a conditional. This means the HTML element renders only if index is greater than 0.
<tr v-for="(i, index) in items" v-bind:key="index" v-if="index > 0">


If you retry everything, you should see that incoming and outgoing amounts are colored differently, and the first row no longer appears. But soon, we'll be doing something bigger.

In computed, add the method sortedItems(). This actually returns the sorted view of the items array.
computed: {
    sortedItems: function() {
        return this.items

    }
},


Here, we use the map() method to iterate through items and return the index and the element as a new object. This serves to preserve both the index, index and the element, item.
computed: {
    sortedItems: function() {
        return this.items
        .map((item, index) => ({ item, index }))
    }
},


And we continue by chaining on a sort() method, sorting by the month property of item. This works because we need the index... but sorting items and adding or removing from it, might change index.
computed: {
    sortedItems: function() {
        return this.items
        .map((item, index) => ({ item, index }))
        .sort((a, b) => a.item.month - b.item.month);
    }
},


Now we'll need to change this. Instead of iterating through items, we iterate through sortedItems and we change all mentions of i to si. Since each element of sortedItems is made of index and item, if we want to refer to the element's properties, we have to refer to it as item.
<tr v-for="si in sortedItems" v-bind:key="si.index" v-if="si.index > 0">
    <td>{{ months[si.item.month] }}</td>
    <td>{{ si.item.name }}</td>
    <td v-bind:class=" si.item.amount > 0 ? 'numeric inText' : 'numeric outText'">{{ si.item.amount }}</td>
    <td></td>
    <td></td>
</tr>


We'll then add two buttons. One is an UPDATE button and will run the setCurrentIndex() method, passing in index as an argument. The other is a DELETE button that runs the removeItem() method, also passing in index as an argument.
<tr v-for="si in sortedItems" v-bind:key="si.index" v-if="si.index > 0">
    <td>{{ months[si.item.month] }}</td>
    <td>{{ si.item.name }}</td>
    <td v-bind:class=" si.item.amount > 0 ? 'numeric inText' : 'numeric outText'">{{ si.item.amount }}</td>
    <td><input type="button" value="UPDATE" @click="setCurrentIndex(si.index)" /></td>
    <td><input type="button" value="DELETE" @click="removeItem(si.index)" /></td>
</tr>


There be buttons! And you may notice, if you enter a June item first and then a February item, they are now sorted properly by month regardless of what order they were entered in. The first default element from items is no longer there, filtered out by the conditional.


Create these two methods. removeItem() has a parameter, index. setCurrentIndex() has a parameter as well, val.
methods: {
    addUpdateItem: function() {
        this.errors.name = "";
        this.errors.amount = "";

        let nameValue = this.$refs.itemName.value.trim();
        let amountValue = parseFloat(this.$refs.itemAmount.value.trim());
        let monthValue = parseInt(this.$refs.itemMonth.value);
        let errors = 0;

        if (nameValue == "") { this.errors.name = "Required"; errors++; }
        if (amountValue <= 0 || isNaN(amountValue)) { this.errors.amount = "Must be positive"; errors++; }
        if (errors > 0) return;

        if (this.$refs.itemTypeOut.checked) amountValue = amountValue * -1;

        if (this.currentIndex == 0) {
            this.items.push({
                name: nameValue,
                month: monthValue,
                amount: amountValue
            });
        }
    },
    removeItem: function(index) {

    },
    setCurrentIndex: function(val) {

    }

}


setCurrentIndex() is straightforward - simply assign the value of val to currentIndex.
removeItem: function(index) {

},
setCurrentIndex: function(val) {
    this.currentIndex = val;
}


removeItem() uses the splice() method to remove the element at position index in items, then resets currentIndex to 0 (just in case it was something else).
removeItem: function(index) {
    this.items.splice(index, 1);
    this.setCurrentIndex(0);

},
setCurrentIndex: function(val) {
    this.currentIndex = val;
}


Now, let's test this. Add this item - "Bonus A" at SGD 5,000 in May. Then add "Bonus B" at SGD 15,000 in June. Click on UPDATE for "Bonus B". setCurrentIndex() should ensure that Bonus B's details appear in the upper right! Also note that the button now says "UPDATE"! That's because currentIndex is no longer 0.


Click on DELETE for "Bonus A". The item vanishes, and setCurrentIndex() changes currentIndex back to 0, so the upper right panel changes as well.


Update the addUpdateItem() method. Before, we only handled the case for currentIndex being 0. Now if currentIndex is not 0, this means it's an update. And we update the values accordingly. The values, of course, have already been validated.
addUpdateItem: function() {
    this.errors.name = "";
    this.errors.amount = "";

    let nameValue = this.$refs.itemName.value.trim();
    let amountValue = parseFloat(this.$refs.itemAmount.value.trim());
    let monthValue = parseInt(this.$refs.itemMonth.value);
    let errors = 0;

    if (nameValue == "") { this.errors.name = "Required"; errors++; }
    if (amountValue <= 0 || isNaN(amountValue)) { this.errors.amount = "Must be positive"; errors++; }
    if (errors > 0) return;

    if (this.$refs.itemTypeOut.checked) amountValue = amountValue * -1;

    if (this.currentIndex == 0) {
        this.items.push({
            name: nameValue,
            month: monthValue,
            amount: amountValue
        });
    } else {
        this.items[this.currentIndex].name = nameValue;
        this.items[this.currentIndex].amount = amountValue;
        this.items[this.currentIndex].month = monthValue;
    }


    this.setCurrentIndex(0);
},


In pnlItem, we add another button. It says "NEW", and when you click on it, it sets currentIndex back to 0. And it renders only if currentIndex is greater than 0.
<p>
    <label for="itemAmount">Amount</label>
    <br />
    <input ref="itemAmount" id="itemAmount" type="number" v-bind:value="Math.abs(items[currentIndex].amount)">
    <span class="error">{{ errors.amount }}</span>
</p>
<input type="button" value="NEW" @click="setCurrentIndex(0)" v-if="currentIndex > 0" />
<input type="button" v-bind:value="currentIndex == 0 ? 'ADD' : 'UPDATE'" @click="addUpdateItem" />


Again, add these items - "Bonus A" at SGD 5,000 in May. Then add "Bonus B" at SGD 15,000 in June. Click UPDATE for Bonus A. The NEW button appears!



Ignore that button for now. Set the amount to 8000 and click UPDATE (the one in the top right corner). It should reflect the new value in the list of items below.


Now click the UPDATE button on "Bonus B". See the NEW button appear again? What happens when you click it? That's right - it should set currentIndex to 0 and give you the "New Item" view.


Next

Showing the Financial Projection.

Friday, 22 May 2026

Web Tutorial: VueJS Financial Projector (Part 1/3)

It was late last year when I consulted my yearly spreadsheet as to whether or not I was on track for my financial goals for 2025. Why, what part surprises you? The fact that I have financial goals, or the fact that I use a spreadsheet? I'm a software developer, FFS.

Either way, Missus TeochewThunder and I review this spreadsheet together regularly. And her not being great with English, Ive had to resort to other ways to bring the message across. Color codes. Simplified columns. And at some point, I wondered if it wouldn't be simpler to just whip up something quick using VueJS. Plus, it would be way more fun.

Here's some HTML. In this, we set font properties. We have a div, id fpApp, in the body. And we include a remote link to the Vue library, along with a standard Vue declaration.
<!DOCTYPE html>
<html>
   <head>
      <title>Financial Projector</title>
      <style>
      body
      {
          font-family: Arial;
         font-size: 14px;
      }
      </style>
  </head>

   <body>
      <div id="fpApp">

      </div>

      <script src="https://cdn.jsdelivr.net/npm/vue@2.7.16/dist/vue.js"></script>

      <script>
      new Vue({
        el: '#fpApp',
        data: {

         },
          computed: {

         },
         methods: {

         }
      });
      </script>
  </body>
</html>


Add three divs inside fpApp - pnlFinancialProjection, pnlItem and pnlItems. Yes, the last one has a plural. Style all of them using the panel CSS class.
<!DOCTYPE html>
<html>
   <head>
      <title>Financial Projector</title>
      <style>
      body
      {
         font-family: Arial;
         font-size: 14px;
      }
      </style>
  </head>

   <body>
      <div id="fpApp">
          <div id="pnlFinancialProjection" class="panel">

         </div>


         <div id="pnlItem" class="panel">


         </div>


         <div id="pnlItems" class="panel">


         </div>

      </div>

      <script src="https://cdn.jsdelivr.net/npm/vue@2.7.16/dist/vue.js"></script>

      <script>
      new Vue({
         el: '#fpApp',
         data: {

         },
         computed: {

         },
         methods: {

         }
     });
      </script>
  </body>
</html>


Let's style panel first. We have rounded corners and an orange outline. Each of them has 10 pixels spacing to the right and bottom, and 10 pixels padding. Most importantly, they are floated left so that they'll align nicely.
<style>
body
{
   font-family: Arial;
   font-size: 14px;
}

.panel
{
   float: left;
   padding: 10px;
   border-radius: 20px;
   outline: 1px solid rgba(255, 150, 0, 0.5);
   margin-right: 10px;
   margin-bottom: 10px;
}

</style>


Then we specify fpApp to have 800 pixels width and be positioned in the middle of the screen via the margin property. pnlFinancialProjection and pnlItem will be 300 pixels tall, and have different widths that will fit inside fpApp, with the padding and spacings factored in. pnlItems has full width (plus padding and spacing) and no height specified. This is because pnlFinancialProjection and pnlItem will deal with finite data, while pnlItems will technically be unlimited.
<style>
body
{
  font-family: Arial;
  font-size: 14px;
}

#fpApp
{
  width: 800px;
  margin: 0 auto 0 auto;
}

#pnlFinancialProjection
{
  width: 500px;
  height: 300px;
}

#pnlItem
{
  width: 240px;
  height: 300px;
}

#pnlItems
{
  width: 770px;
}


.panel
{
   float: left;
   padding: 10px;
   border-radius: 20px;
   outline: 1px solid rgba(255, 150, 0, 0.5);
   margin-right: 10px;
   margin-bottom: 10px;
}
</style>


Looks nice and neat so far. We'll be filling this in.


Before we continue, let's fill in some data. In data, we want the items array. The first element at index 0 is the default item that will be used for display when adding to this array, as a template. The three properties are name (an empty string) and month and amount (both integers with a value of 0).
data: {
   items: [
      {
        name: "",
        month: 0,
        amount: 0
      }
   ],
}


Then we have currentIndex, default value 0.
data: {
   currentIndex: 0,
   items: [
      {
        name: "",
        month: 0,
        amount: 0
      }
   ],
},


With this, we can basically try making a form for adding new elements to items. We have a paragraph, and in it there's a label tag. Then we want the corresponding element, a select tag. Both id and ref attributes will be itemMonth.
<div id="pnlItem" class="panel">
   <p>
      <label for="itemMonth">Month</label>

      <br />
      <select ref="itemMonth" id="itemMOnth">

      </select>

  </p>
</div>


Now this is a dropdown list of months, and we're going to populate this using data. For this, we create the months array in data. We then populate it using month names. The first element, at index 0, is "All".
data: {
   currentIndex: 0,
   items: [
      {
        name: "",
        month: 0,
        amount: 0
      }
   ],
   months: [
      "All", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"

  ]   
},


In the HTML, we create a series of option tags by using the v-for attribute to iterate through months.
<select ref="itemMonth" id="itemMonth">
   <option v-for="(month, index) in months">

   </option>

</select>


month, of course, will be used as the label for the option tags.
<select ref="itemMonth" id="itemMonth">
   <option v-for="(month, index) in months">
      {{ month }}
   </option>
</select>


Here, we use the v-bind directive to ensure that every element has a unique key, and a value. In both cases, it is index. And also bind the selected property to whether index is equal to the month property of the current element of items pointed to by currentIndex.
<select ref="itemMonth" id="itemMonth">
   <option v-for="(month, index) in months" v-bind:key="index" v-bind:value="index" v-bind:selected="items[currentIndex].month == index">
      {{ month }}
   </option>
</select>


There you go, there's your dropdown list.


The next element is a text box, itemName. Since currentIndex is 0, what you see is that the textbox's value is set to the value of the first element of items, which is an empty string.
<div id="pnlItem" class="panel">
  <p>
    <label for="itemMonth">Month</label>
    <br />
    <select ref="itemMonth" id="itemMonth">
     <option v-for="(month, index) in months" v-bind:key="index" v-bind:value="index" v-bind:selected="items[currentIndex].month == index">
        {{ month }}
      </option>
    </select>
  </p>
  <p>
    <label for="itemName">Name</label>
    <br />
    <input ref="itemName" id="itemName" type="text" v-bind:value="items[currentIndex].name">
  </p>

</div>


The next elements are radio buttons which determine if the entry is income or expense - hence itemTypeIn or itemTypeOut. If the first element of items has an amount of 0 or more, itemTypeIn is checked; otherwise itemTypeOut is checked. We set the name attribute to type for both radio buttons so that only one can be selected.
  <p>
      <label for="itemName">Name</label>
      <br />
      <input ref="itemName" id="itemName" type="text" v-bind:value="items[currentIndex].name">
  </p>
   <p>
      <label for="itemTypeIn">Type</label>

      <br />
      <input ref="itemTypeIn" id="itemTypeIn" name="type" type="radio" v-bind:checked="items[currentIndex].amount >= 0">In
      <input ref="itemTypeOut" id="itemTypeOut" name="type" type="radio" v-bind:checked="items[currentIndex].amount < 0">Out
  </p>
</div>


Now we have the itemAmount textbox for the amount property, and this is straightforward... except that instead of just placing the value of amount in the textbox, we want the absolute value. Because, later on, depending on whether it is income or an expense, the value will be positive or negative.
  <p>
    <label for="itemTypeIn">Type</label>
    <br />
    <input ref="itemTypeIn" id="itemTypeIn" name="type" type="radio" v-bind:checked="items[currentIndex].amount >= 0">In
    <input ref="itemTypeOut" id="itemTypeOut" name="type" type="radio" v-bind:checked="items[currentIndex].amount < 0">Out
   </p>
   <p>
    <label for="itemAmount">Amount</label>
    <br />
    <input ref="itemAmount" id="itemAmount" type="number" v-bind:value="Math.abs(items[currentIndex].amount)">
   </p>

</div>


Here's the rest of the input elements.


And then we add a button. It will say "ADD" or "UPDATE" based on the value of currentIndex. Since currentIndex is 0, it will say "ADD". As you can see, it also runs the addUpdateItem() method when clicked.
  <p>
      <label for="itemAmount">Amount</label>
      <br />
      <input ref="itemAmount" id="itemAmount" type="number" v-bind:value="Math.abs(items[currentIndex].amount)">
  </p>
  <input type="button" v-bind:value="currentIndex == 0 ? 'ADD' : 'UPDATE'" @click="addUpdateItem" />
</div>


Here is the button. See? It says "ADD".


In the styles, here is some styling for what we've done so far. It's all cosmetic, but let's keep things pretty.
.panel
{
   float: left;
   padding: 10px;
   border-radius: 20px;
   outline: 1px solid rgba(255, 150, 0, 0.5);
   margin-right: 10px;
   margin-bottom: 10px;
}

label
{
   width: 5em;
   font-size: 0.5em;
   float: left;
}

input[type=text], input[type=number], select
{
   width: 10em;
   padding: 0em;
}

input[type=button]
{
   width: 5em;
   height: 1.5em;
   float: right;
   margin-left: 0.5em;
   border-radius: 5px;
   background-color: rgba(255, 150, 0, 0.5);
   color: rgb(255, 255, 255);
   border: 0px solid rgb(0, 0, 0);
}

input[type=button]:hover
{
   background-color: rgba(255, 150, 0, 1);
}

</style>


A nicely formatted form.


Let's handle adding stuff!

In data, cater for error handling with the object errors. There will be only two error types, so set two properties - name and amount. Both are set to an empty string by default.
data: {
   currentIndex: 0,
   errors: {
      name: "",

      amount: "",
   },
   items:[
      {
        name: "",
        month: 0,
        amount: 0
      }
   ],
   months: [
      "All", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
  ]
},


In methods, create the method addUpdateItem(). This is the method that will be called when the button is clicked, whether it's an add or update.
methods: {
   addUpdateItem: function() {

   }

}


We start by setting (or in some contexts, resetting) the properties of errors to empty strings.
methods: {
  addUpdateItem: function() {
    this.errors.name = "";
    this.errors.amount = "";

  }
}


Then we grab the values of the various elements and assign them to variables. In particular, we want name, amount and month. We also declare errors as 0, to be incremented when there are errors.
methods: {
  addUpdateItem: function() {
    this.errors.name = "";
    this.errors.amount = "";

    let nameValue = this.$refs.itemName.value.trim();
    let amountValue = parseFloat(this.$refs.itemAmount.value.trim());
    let monthValue = parseInt(this.$refs.itemMonth.value);
    let errors = 0;

  }
}


So next we check if nameValue is an empty string, and if amountValue is a positive number. These are the only two conditions we need. For either case, set the error in the appropriate errors object, and increment errors. After that, check if errors is greater than 0, and exit early if so.
let nameValue = this.$refs.itemName.value.trim();
let amountValue = parseFloat(this.$refs.itemAmount.value.trim());
let monthValue = parseInt(this.$refs.itemMonth.value);
let errors = 0;

if (nameValue == "") { this.errors.name = "Required"; errors++; }
if (amountValue <= 0 || isNaN(amountValue)) { this.errors.amount = "Must be positive"; errors++; }

if (errors > 0) return;


Add these span tags to the HTML. In it, you should have binding for the appropriate properties in the errors object.
<p>
   <label for="itemName">Name</label>
   <br />
   <input ref="itemName" id="itemName" type="text" v-bind:value="items[currentIndex].name">
   <span class="error">{{ errors.name }}</span>
</p>
<p>
   <label for="itemTypeIn">Type</label>
   <br />
   <input ref="itemTypeIn" id="itemTypeIn" name="type" type="radio" v-bind:checked="items[currentIndex].amount >= 0">In
   <input ref="itemTypeOut" id="itemTypeOut" name="type" type="radio" v-bind:checked="items[currentIndex].amount < 0">Out
</p>
<p>
   <label for="itemAmount">Amount</label>
   <br />
   <input ref="itemAmount" id="itemAmount" type="number" v-bind:value="Math.abs(items[currentIndex].amount)">
   <span class="error">{{ errors.amount }}</span>
</p>


In styles, I'll have error be red, in a smaller font and bold.
input[type=button]:hover
{
   background-color: rgba(255, 150, 0, 1);
}

.error
{
   color: rgb(255, 0, 0);
   font-size: 0.8em;
   font-weight: bold;
}

</style>


Click the ADD button without changing any values. There, that's straightforward. See the errors?


Back to the method. We now set amountValue. If it's an expense, which means itemTypeOut is checked, then we set amountValue to a negative value.
if (nameValue == "") { this.errors.name = "Required"; errors++; }
if (amountValue <= 0 || isNaN(amountValue)) { this.errors.amount = "Must be positive"; errors++; }
if (errors > 0) return;

if (this.$refs.itemTypeOut.checked) amountValue = amountValue * -1;


Next, we check if currentIndex is 0. At this point, it will always be true. Later on, we'll cater for other scenarios.
if (nameValue == "") { this.errors.name = "Required"; errors++; }
if (amountValue <= 0 || isNaN(amountValue)) { this.errors.amount = "Must be positive"; errors++; }
if (errors > 0) return;

if (this.$refs.itemTypeOut.checked) amountValue = amountValue * -1;

if (this.currentIndex == 0) {

}


So if currentIndex is 0, which means that this is an ADD operation, push the values into items.
if (this.currentIndex == 0) {
   this.items.push({
      name: nameValue,

      month: monthValue,
      amount: amountValue
   });
}


You won't be able to see anything now even if stuff gets added into items. But no worries, that's handled in the next part!

Next

Displaying items added, updating and removing them.