diff --git a/README.md b/README.md index 6f6de5a..58163bc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ -->

Hammond

-

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 { Create Expense + Mark Processed -

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 { - + 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 { +
+
+

Stats

+
+ + +
+
+ +
diff --git a/ui/src/state/modules/vehicles.js b/ui/src/state/modules/vehicles.js index d9282be..ba5fef8 100644 --- a/ui/src/state/modules/vehicles.js +++ b/ui/src/state/modules/vehicles.js @@ -165,4 +165,11 @@ export const actions = { return data }) }, + deleteQuickEntry({ commit, state, rootState, dispatch }, { id }) { + return axios.delete(`/api/quickEntries/${id}`).then((response) => { + const data = response.data + dispatch('fetchQuickEntries', { force: true }) + return data + }) + }, }
Current Version2021.07.232021.08.13
Website