diff --git a/README.md b/README.md index 6f6de5a..58163bc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ -->
Current Version - 2021.07.23
+Current Version - 2021.08.13
A self-hosted vehicle expense tracking system with support for multiple users.
diff --git a/server/controllers/files.go b/server/controllers/files.go
index f67a86a..8831572 100644
--- a/server/controllers/files.go
+++ b/server/controllers/files.go
@@ -19,6 +19,8 @@ func RegisterFilesController(router *gin.RouterGroup) {
router.GET("/me/quickEntries", getMyQuickEntries)
router.GET("/quickEntries/:id", getQuickEntryById)
router.POST("/quickEntries/:id/process", setQuickEntryAsProcessed)
+ router.DELETE("/quickEntries/:id", deleteQuickEntryById)
+
router.GET("/attachments/:id/file", getAttachmentFile)
}
@@ -76,6 +78,21 @@ func getQuickEntryById(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
}
}
+
+func deleteQuickEntryById(c *gin.Context) {
+ var searchByIdQuery models.SearchByIdQuery
+
+ if c.ShouldBindUri(&searchByIdQuery) == nil {
+ err := service.DeleteQuickEntryById(searchByIdQuery.Id)
+ if err != nil {
+ c.JSON(http.StatusUnprocessableEntity, common.NewError("deleteQuickEntryById", err))
+ return
+ }
+ c.JSON(http.StatusNoContent, gin.H{})
+ } else {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
+ }
+}
func setQuickEntryAsProcessed(c *gin.Context) {
var searchByIdQuery models.SearchByIdQuery
diff --git a/server/controllers/reports.go b/server/controllers/reports.go
new file mode 100644
index 0000000..6284ffa
--- /dev/null
+++ b/server/controllers/reports.go
@@ -0,0 +1,37 @@
+package controllers
+
+import (
+ "net/http"
+
+ "github.com/akhilrex/hammond/common"
+ "github.com/akhilrex/hammond/models"
+ "github.com/akhilrex/hammond/service"
+ "github.com/gin-gonic/gin"
+)
+
+func RegisterReportsController(router *gin.RouterGroup) {
+ router.GET("/vehicles/:id/mileage", getMileageForVehicle)
+}
+
+func getMileageForVehicle(c *gin.Context) {
+
+ var searchByIdQuery models.SearchByIdQuery
+
+ if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
+ var model models.MileageQueryModel
+ err := c.BindQuery(&model)
+ if err != nil {
+ c.JSON(http.StatusUnprocessableEntity, common.NewError("getMileageForVehicle", err))
+ return
+ }
+
+ fillups, err := service.GetMileageByVehicleId(searchByIdQuery.Id, model.Since)
+ if err != nil {
+ c.JSON(http.StatusUnprocessableEntity, common.NewError("getMileageForVehicle", err))
+ return
+ }
+ c.JSON(http.StatusOK, fillups)
+ } else {
+ c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
+ }
+}
diff --git a/server/db/dbfunctions.go b/server/db/dbfunctions.go
index f2142e7..363918d 100644
--- a/server/db/dbfunctions.go
+++ b/server/db/dbfunctions.go
@@ -160,6 +160,11 @@ func GetFillupsByVehicleId(id string) (*[]Fillup, error) {
result := DB.Preload(clause.Associations).Order("date desc").Find(&obj, &Fillup{VehicleID: id})
return &obj, result.Error
}
+func GetFillupsByVehicleIdSince(id string, since time.Time) (*[]Fillup, error) {
+ var obj []Fillup
+ result := DB.Where("date >= ? AND vehicle_id = ?", since, id).Preload(clause.Associations).Order("date desc").Find(&obj)
+ return &obj, result.Error
+}
func FindFillups(condition interface{}) (*[]Fillup, error) {
var model []Fillup
@@ -237,6 +242,10 @@ func GetQuickEntryById(id string) (*QuickEntry, error) {
result := DB.Preload(clause.Associations).First(&quickEntry, "id=?", id)
return &quickEntry, result.Error
}
+func DeleteQuickEntryById(id string) error {
+ result := DB.Where("id=?", id).Delete(&QuickEntry{})
+ return result.Error
+}
func UpdateQuickEntry(entry *QuickEntry) error {
return DB.Save(entry).Error
}
diff --git a/server/main.go b/server/main.go
index bbb96d7..1d320dc 100644
--- a/server/main.go
+++ b/server/main.go
@@ -50,6 +50,7 @@ func main() {
controllers.RegisterVehicleController(router)
controllers.RegisterFilesController(router)
controllers.RegisteImportController(router)
+ controllers.RegisterReportsController(router)
go assetEnv()
go intiCron()
diff --git a/server/models/report.go b/server/models/report.go
new file mode 100644
index 0000000..40e5403
--- /dev/null
+++ b/server/models/report.go
@@ -0,0 +1,38 @@
+package models
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/akhilrex/hammond/db"
+)
+
+type MileageModel struct {
+ Date time.Time `form:"date" json:"date" binding:"required" time_format:"2006-01-02"`
+ VehicleID string `form:"vehicleId" json:"vehicleId" binding:"required"`
+ FuelUnit db.FuelUnit `form:"fuelUnit" json:"fuelUnit" binding:"required"`
+ FuelQuantity float32 `form:"fuelQuantity" json:"fuelQuantity" binding:"required"`
+ PerUnitPrice float32 `form:"perUnitPrice" json:"perUnitPrice" binding:"required"`
+ Currency string `json:"currency"`
+
+ Mileage float32 `form:"mileage" json:"mileage" binding:"mileage"`
+ CostPerMile float32 `form:"costPerMile" json:"costPerMile" binding:"costPerMile"`
+ OdoReading int `form:"odoReading" json:"odoReading" binding:"odoReading"`
+}
+
+func (v *MileageModel) FuelUnitDetail() db.EnumDetail {
+ return db.FuelUnitDetails[v.FuelUnit]
+}
+func (b *MileageModel) MarshalJSON() ([]byte, error) {
+ return json.Marshal(struct {
+ MileageModel
+ FuelUnitDetail db.EnumDetail `json:"fuelUnitDetail"`
+ }{
+ MileageModel: *b,
+ FuelUnitDetail: b.FuelUnitDetail(),
+ })
+}
+
+type MileageQueryModel struct {
+ Since time.Time `json:"since" query:"since" form:"since"`
+}
diff --git a/server/service/fileService.go b/server/service/fileService.go
index fb56a64..c2d40c8 100644
--- a/server/service/fileService.go
+++ b/server/service/fileService.go
@@ -58,6 +58,9 @@ func GetQuickEntriesForUser(userId, sorting string) (*[]db.QuickEntry, error) {
func GetQuickEntryById(id string) (*db.QuickEntry, error) {
return db.GetQuickEntryById(id)
}
+func DeleteQuickEntryById(id string) error {
+ return db.DeleteQuickEntryById(id)
+}
func SetQuickEntryAsProcessed(id string) error {
return db.SetQuickEntryAsProcessed(id, time.Now())
diff --git a/server/service/importService.go b/server/service/importService.go
index 237b0b7..dbd5eab 100644
--- a/server/service/importService.go
+++ b/server/service/importService.go
@@ -97,22 +97,23 @@ func FuellyImport(content []byte, userId string) []string {
)
isTankFull := record[6] == "Full"
-
+ fal := false
fillups = append(fillups, db.Fillup{
- VehicleID: vehicle.ID,
- FuelUnit: vehicle.FuelUnit,
- FuelQuantity: quantity,
- PerUnitPrice: rate,
- TotalAmount: totalCost,
- OdoReading: odoreading,
- IsTankFull: &isTankFull,
- Comments: notes,
- FillingStation: location,
- UserID: userId,
- Date: date,
- Currency: user.Currency,
- DistanceUnit: user.DistanceUnit,
- Source: "Fuelly",
+ VehicleID: vehicle.ID,
+ FuelUnit: vehicle.FuelUnit,
+ FuelQuantity: quantity,
+ PerUnitPrice: rate,
+ TotalAmount: totalCost,
+ OdoReading: odoreading,
+ IsTankFull: &isTankFull,
+ Comments: notes,
+ FillingStation: location,
+ HasMissedFillup: &fal,
+ UserID: userId,
+ Date: date,
+ Currency: user.Currency,
+ DistanceUnit: user.DistanceUnit,
+ Source: "Fuelly",
})
}
diff --git a/server/service/reportService.go b/server/service/reportService.go
new file mode 100644
index 0000000..f0e7b93
--- /dev/null
+++ b/server/service/reportService.go
@@ -0,0 +1,52 @@
+package service
+
+import (
+ "time"
+
+ "github.com/akhilrex/hammond/db"
+ "github.com/akhilrex/hammond/models"
+)
+
+func GetMileageByVehicleId(vehicleId string, since time.Time) (mileage []models.MileageModel, err error) {
+ data, err := db.GetFillupsByVehicleIdSince(vehicleId, since)
+ if err != nil {
+ return nil, err
+ }
+
+ fillups := make([]db.Fillup, len(*data))
+ copy(fillups, *data)
+
+ var mileages []models.MileageModel
+
+ for i := 0; i < len(fillups)-1; i++ {
+ last := i + 1
+
+ currentFillup := fillups[i]
+ lastFillup := fillups[last]
+
+ mileage := models.MileageModel{
+ Date: currentFillup.Date,
+ VehicleID: currentFillup.VehicleID,
+ FuelUnit: currentFillup.FuelUnit,
+ FuelQuantity: currentFillup.FuelQuantity,
+ PerUnitPrice: currentFillup.PerUnitPrice,
+ OdoReading: currentFillup.OdoReading,
+ Currency: currentFillup.Currency,
+ Mileage: 0,
+ CostPerMile: 0,
+ }
+
+ if currentFillup.IsTankFull != nil && *currentFillup.IsTankFull && (currentFillup.HasMissedFillup == nil || !(*currentFillup.HasMissedFillup)) {
+ distance := float32(currentFillup.OdoReading - lastFillup.OdoReading)
+ mileage.Mileage = distance / currentFillup.FuelQuantity
+ mileage.CostPerMile = distance / currentFillup.TotalAmount
+
+ }
+
+ mileages = append(mileages, mileage)
+ }
+ if mileages == nil {
+ mileages = make([]models.MileageModel, 0)
+ }
+ return mileages, nil
+}
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 5992290..577fd9e 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -1586,6 +1586,14 @@
"@babel/types": "^7.3.0"
}
},
+ "@types/chart.js": {
+ "version": "2.9.34",
+ "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.34.tgz",
+ "integrity": "sha512-CtZVk+kh1IN67dv+fB0CWmCLCRrDJgqOj15qPic2B1VCMovNO6B7Vhf/TgPpNscjhAL1j+qUntDMWb9A4ZmPTg==",
+ "requires": {
+ "moment": "^2.10.2"
+ }
+ },
"@types/glob": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
@@ -5138,6 +5146,32 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
+ "chart.js": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz",
+ "integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==",
+ "requires": {
+ "chartjs-color": "^2.1.0",
+ "moment": "^2.10.2"
+ }
+ },
+ "chartjs-color": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
+ "integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
+ "requires": {
+ "chartjs-color-string": "^0.6.0",
+ "color-convert": "^1.9.3"
+ }
+ },
+ "chartjs-color-string": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
+ "integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
+ "requires": {
+ "color-name": "^1.0.0"
+ }
+ },
"check-types": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/check-types/-/check-types-8.0.3.tgz",
@@ -5621,7 +5655,6 @@
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
- "dev": true,
"requires": {
"color-name": "1.1.3"
}
@@ -5629,8 +5662,7 @@
"color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
- "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
- "dev": true
+ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
},
"color-string": {
"version": "1.5.5",
@@ -13850,6 +13882,11 @@
"minimist": "^1.2.5"
}
},
+ "moment": {
+ "version": "2.29.1",
+ "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
+ "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
+ },
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@@ -19850,6 +19887,14 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-2.6.11.tgz",
"integrity": "sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ=="
},
+ "vue-chartjs": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-3.5.1.tgz",
+ "integrity": "sha512-foocQbJ7FtveICxb4EV5QuVpo6d8CmZFmAopBppDIGKY+esJV8IJgwmEW0RexQhxqXaL/E1xNURsgFFYyKzS/g==",
+ "requires": {
+ "@types/chart.js": "^2.7.55"
+ }
+ },
"vue-eslint-parser": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-7.6.0.tgz",
diff --git a/ui/package.json b/ui/package.json
index aa4cbbc..3ed6b03 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -36,6 +36,7 @@
"@fortawesome/vue-fontawesome": "0.1.9",
"axios": "0.19.2",
"buefy": "^0.9.7",
+ "chart.js": "^2.9.4",
"core-js": "3.6.4",
"currency-formatter": "^1.5.7",
"date-fns": "2.10.0",
@@ -43,6 +44,7 @@
"normalize.css": "8.0.1",
"nprogress": "0.2.0",
"vue": "2.6.11",
+ "vue-chartjs": "^3.5.1",
"vue-meta": "2.3.3",
"vue-router": "3.1.6",
"vuex": "3.1.2"
diff --git a/ui/src/components/mileageChart.vue b/ui/src/components/mileageChart.vue
new file mode 100644
index 0000000..a917529
--- /dev/null
+++ b/ui/src/components/mileageChart.vue
@@ -0,0 +1,54 @@
+
diff --git a/ui/src/router/views/quickEntries.vue b/ui/src/router/views/quickEntries.vue
index 3385c62..e0775f8 100644
--- a/ui/src/router/views/quickEntries.vue
+++ b/ui/src/router/views/quickEntries.vue
@@ -40,6 +40,12 @@ export default {
markProcessed(entry) {
store.dispatch('vehicles/setQuickEntryAsProcessed', { id: entry.id }).then((data) => {})
},
+ deleteQuickEntry(entry) {
+ var sure = confirm('This will delete this Quick Entry. This step cannot be reversed. Are you sure?')
+ if (sure) {
+ store.dispatch('vehicles/deleteQuickEntry', { id: entry.id }).then((data) => {})
+ }
+ },
imageModal(url) {
const h = this.$createElement
const vnode = h('p', { class: 'image' }, [h('img', { attrs: { src: url } })])
@@ -86,8 +92,10 @@ export default {
Processed on {{ parseAndFormatDateTime(entry.processDate) }}
+ + Delete diff --git a/ui/src/router/views/settings.vue b/ui/src/router/views/settings.vue index a0080f6..6f67fdc 100644 --- a/ui/src/router/views/settings.vue +++ b/ui/src/router/views/settings.vue @@ -181,7 +181,7 @@ export default {| Current Version | -2021.07.23 | +2021.08.13 |
| Website | diff --git a/ui/src/router/views/vehicle.vue b/ui/src/router/views/vehicle.vue index 3c96d46..2c1bdfc 100644 --- a/ui/src/router/views/vehicle.vue +++ b/ui/src/router/views/vehicle.vue @@ -2,10 +2,13 @@ import Layout from '@layouts/main.vue' import { parseAndFormatDate } from '@utils/format-date' import { mapState } from 'vuex' +import { addDays, addMonths } from 'date-fns' import axios from 'axios' import currencyFormtter from 'currency-formatter' import store from '@state/store' import ShareVehicle from '@components/shareVehicle.vue' +import MileageChart from '@components/mileageChart.vue' + export default { page() { return { @@ -18,7 +21,7 @@ export default { ], } }, - components: { Layout }, + components: { Layout, MileageChart }, props: { vehicle: { type: Object, @@ -36,6 +39,15 @@ export default { title: '', stats: null, users: [], + dateRangeOptions: [ + { label: 'This week', value: 'this_week' }, + { label: 'This month', value: 'this_month' }, + { label: 'Past 30 days', value: 'past_30_days' }, + { label: 'Past 3 months', value: 'past_3_months' }, + { label: 'This year', value: 'this_year' }, + { label: 'All Time', value: 'all_time' }, + ], + dateRangeOption: 'past_30_days', } }, computed: { @@ -80,6 +92,7 @@ export default { }) }, }, + mounted() { this.fetchFillups() this.fetchExpenses() @@ -105,6 +118,7 @@ export default { }) .catch((err) => console.log(err)) }, + fetchExpenses() { axios .get(`/api/vehicles/${this.vehicle.id}/expenses`) @@ -245,6 +259,33 @@ export default { }, }) }, + getStartDate() { + const toDate = new Date() + switch (this.dateRangeOption) { + case 'this_week': + var currentDayOfWeek = toDate.getDay() + var toSubtract = 0 + if (currentDayOfWeek === 0) { + toSubtract = -6 + } + if (currentDayOfWeek > 1) { + toSubtract = -1 * (currentDayOfWeek - 1) + } + return addDays(toDate, toSubtract) + case 'this_month': + return new Date(toDate.getFullYear(), toDate.getMonth(), 1) + case 'past_30_days': + return addDays(toDate, -30) + case 'past_3_months': + return addMonths(toDate, -3) + case 'this_year': + return new Date(toDate.getFullYear(), 0, 1) + case 'all_time': + return new Date(1969, 4, 20) + default: + return new Date(1969, 4, 20) + } + }, }, } @@ -477,5 +518,18 @@ export default { No Attachments so far +