Compare commits

...

169 Commits

Author SHA1 Message Date
Alf Sebastian Houge
ea3423d32a Merge pull request #69 from AlfHou/AlfHou-patch-1
Update Dockerfile
2023-07-20 12:14:16 +02:00
Alf Sebastian Houge
6542a3bb28 Update Dockerfile 2023-07-20 12:14:05 +02:00
Alf Sebastian Houge
35d2f1ca0b Merge pull request #68 from AlfHou/AlfHou-patch-1 2023-07-20 01:15:31 +02:00
Alf Sebastian Houge
565d5701be Update Dockerfile to use Debian instead of alpine 2023-07-20 01:15:09 +02:00
Alf Sebastian Houge
2eb78ab73c Merge pull request #67 from AlfHou/AlfHou-patch-1
Update Dockerfile
2023-07-20 00:57:24 +02:00
Alf Sebastian Houge
edf4647549 Update Dockerfile 2023-07-20 00:57:15 +02:00
Alf Sebastian Houge
e87a348b90 Merge pull request #66 from AlfHou/AlfHou-patch-1
Add autoconf dependency to dockerfile
2023-07-20 00:48:13 +02:00
Alf Sebastian Houge
c15b22c71a Update Dockerfile 2023-07-20 00:47:52 +02:00
Alf Sebastian Houge
1c9f9c7803 Merge pull request #65 from AlfHou/AlfHou-patch-1
Update hub.yml
2023-07-20 00:40:23 +02:00
Alf Sebastian Houge
89bdfdefd4 Update hub.yml 2023-07-20 00:40:13 +02:00
Alf Sebastian Houge
2974fd783f Merge pull request #64 from AlfHou/AlfHou-patch-1
Fix typo in hub.yml
2023-07-20 00:37:42 +02:00
Alf Sebastian Houge
bd22b5a497 Fix typo in hub.yml 2023-07-20 00:36:08 +02:00
Alf Sebastian Houge
8f6408a92b Merge pull request #63 from AlfHou/AlfHou-patch-1
Update github workflow for publishing releases
2023-07-20 00:30:13 +02:00
Alf Sebastian Houge
20bc28fffa Update workflow for publishing releases 2023-07-20 00:28:44 +02:00
Alf Sebastian Houge
3c89e75a34 Merge pull request #62 from AlfHou/AlfHou-patch-2
Update Dockerfile
2023-07-19 23:27:46 +02:00
Alf Sebastian Houge
5594356166 Update Dockerfile 2023-07-19 23:25:20 +02:00
Alf Sebastian Houge
cea08a59be Merge pull request #61 from AlfHou/AlfHou-patch-2
Update Dockerfile
2023-07-19 23:23:22 +02:00
Alf Sebastian Houge
f16ed1a39f Update Dockerfile 2023-07-19 23:23:03 +02:00
Alf Sebastian Houge
66032fcf55 Merge pull request #60 from AlfHou/AlfHou-patch-2
Update Dockerfile
2023-07-19 15:46:55 +02:00
Alf Sebastian Houge
34a9d56726 Update Dockerfile 2023-07-19 15:45:30 +02:00
Alf Sebastian Houge
a0880ad5b6 Merge pull request #58 from AlfHou/AlfHou-patch-2
Update go and npm version in Dockerfile
2023-07-19 11:24:11 +02:00
Alf Sebastian Houge
17e8e5914e Update go and npm version in Dockerfile 2023-07-19 11:19:15 +02:00
Alf Sebastian Houge
a14f298822 Merge pull request #56 from AlfHou/feat/copy-odometer-to-fillup
Copy last odometer value to new fillup
2023-07-18 22:50:33 +02:00
Alf Sebastian Houge
afe4078897 Copy last odometer value to new fillup 2023-07-14 16:25:47 +02:00
Alf Sebastian Houge
01f9b455cf Merge pull request #55 from AlfHou/AlfHou-patch-1
Update README.md
2023-07-14 10:28:31 +02:00
Alf Sebastian Houge
c9c06f865c Update README.md 2023-07-14 10:26:40 +02:00
Alf Sebastian Houge
e2c14afc99 Merge pull request #49 from alienp4nda/feat/generic-import
Feat/generic import
2023-04-27 08:51:13 +02:00
Alex H
415d0abc83 removed references to json, clearly stated import is only for fillups 2023-04-21 20:47:16 -04:00
Alex H
d32fd8073d fixed tank full radio not highlighting when selected 2023-04-19 20:23:53 -04:00
Alex H
d6eab70ca6 string value of not/filled is now case insensitive 2023-04-19 02:36:00 +00:00
Alex H
cc82536970 fixed error in if logic which caused all fields to be returned as true 2023-04-19 02:24:28 +00:00
Alex H
094cf0d7c9 added the ability to clear non-required fields, added a new icon 2023-04-17 22:19:01 -04:00
Alex H
24f295c632 forgot to remove a print statement 2023-04-17 21:17:32 -04:00
Alex H
e9812e7e27 fixed dateLayout, fixed variable order in call to ParseInLocation, code formatting 2023-04-17 21:10:31 -04:00
Alex H
785ff9a089 improper totalAmount comparison, wrong case on object prop 2023-04-17 21:08:20 -04:00
Alex H
b99c3921d7 added logic to convert numbers to booleans, fixed capitalization issues 2023-04-17 21:43:23 +00:00
Alex H
d64777dca6 fixed if statement, if some npm warnings 2023-04-17 00:59:18 -04:00
Alex H
5208437ec2 initial generic import backend code 2023-04-16 05:59:32 +00:00
Alex H
654087b990 dates to ISO strings, getTimeZone gets timezone as string 2023-04-16 05:58:55 +00:00
Alex H
d294db34fc replace v-if with a v-else, marked all required fields required 2023-04-14 23:57:49 -04:00
Alex H
9f9f90fd1d handling isFullTankString and making post request to api 2023-04-14 23:20:44 -04:00
Alex H
051e3476a7 csvToJson is mostly complete 2023-04-14 20:45:12 -04:00
Alex H
845dcb242a renamed 4 variables to better describe what they're for, fixed if logic 2023-04-14 19:07:32 -04:00
Alex H
df165dae6e added full tank string value inputs 2023-04-14 01:54:03 -04:00
Alex H
e389a9ac2a forgot to commit package.json after I added papaparse 2023-04-13 22:57:01 -04:00
Alex H
e2e4169787 user selects if they track full tank or partial fillup 2023-04-13 22:55:09 -04:00
Alex H
2a8325c6ce initial server side processing 2023-04-13 17:52:13 +00:00
Alex H
cd2e9ebc61 first interation of a generic csv importer 2023-04-13 07:32:24 +00:00
Alex H
1ac3a8b31b import-generic view and route added 2023-04-13 07:13:17 +00:00
Alf Sebastian Houge
f07922763b Merge pull request #41 from AlfHou/chore/change-go-module-name
Change go module name
2023-02-16 22:38:13 +01:00
Alf Sebastian Houge
9da21b2192 Change go module name 2023-02-16 22:35:03 +01:00
Alf Sebastian Houge
a8c85bcd7d Merge pull request #34 from AlfHou/chore/update-dependencies
Chore/update dependencies
2023-02-12 21:06:27 +01:00
Alf Sebastian Houge
d597a4ed30 Fix sizing issues in font awesome icons 2023-02-12 17:38:26 +01:00
Alf Sebastian Houge
45456280b4 Fix scss warnings 2023-02-07 21:00:20 +01:00
Alf Sebastian Houge
1d5794e344 Update lint staged 2023-02-07 20:36:08 +01:00
Alf Sebastian Houge
cd558ba744 Update buefy 2023-02-07 20:34:51 +01:00
Alf Sebastian Houge
3a2c82c789 Fix warnings and translation errors in user creator 2023-02-05 00:39:21 +01:00
Alf Sebastian Houge
d196536d74 Update vue packages 2023-02-04 23:35:07 +01:00
Alf Sebastian Houge
630a7f2ec6 Update vuex and vue router 2023-02-04 23:28:09 +01:00
Alf Sebastian Houge
f2bc01289a update icon fonts 2023-02-04 22:04:19 +01:00
Alf Sebastian Houge
a8d2b37087 Fix eslint errors 2023-02-04 22:01:27 +01:00
Alf Sebastian Houge
7436399d90 Update eslint 2023-02-04 21:48:16 +01:00
Alf Sebastian Houge
bc3e1f0982 Update stylelint-scss 2023-02-04 21:43:56 +01:00
Alf Sebastian Houge
d429fa34bd Update hygen 2023-02-04 21:43:09 +01:00
Alf Sebastian Houge
5095cb4c61 Update lintstaged 2023-02-04 21:41:48 +01:00
Alf Sebastian Houge
e0df7ee80e Update markdown-cli 2023-02-04 21:38:53 +01:00
Alf Sebastian Houge
431de8c3eb Update sass-loader 2023-02-04 21:36:47 +01:00
Alf Sebastian Houge
41793784ea Update stylelint 2023-02-04 21:34:34 +01:00
Alf Sebastian Houge
85b5ad28bf Update core.js 2023-02-04 21:22:17 +01:00
Alf Sebastian Houge
b386012e13 Use node version 16 and higher 2023-02-04 21:14:26 +01:00
Alf Sebastian Houge
df2d7288df Update caniuse browser list 2023-02-04 15:41:42 +01:00
Alf Sebastian Houge
7a6f796561 Update date-fns 2023-02-04 15:25:32 +01:00
Alf Sebastian Houge
aee52d0594 Update icon fonts 2023-02-04 15:16:54 +01:00
Alf Sebastian Houge
f8b1de8d15 Update eslint dependencies 2023-02-04 14:56:07 +01:00
Alf Sebastian Houge
a7896340e1 Update express 2023-02-04 14:03:37 +01:00
Alf Sebastian Houge
f2a3bb2e9f Update lint-staged 2023-02-04 14:01:44 +01:00
Alf Sebastian Houge
d343619f13 Update sass 2023-02-04 13:58:56 +01:00
Alf Sebastian Houge
adce0efa8b Update stylelint dependency 2023-02-04 13:57:28 +01:00
Alf Sebastian Houge
fc6f4bc00d Update stylelint-config-recess-order dependency 2023-02-04 13:55:27 +01:00
Alf Sebastian Houge
a16bcf850f Update stylelint-scss 2023-02-04 13:53:51 +01:00
Alf Sebastian Houge
4ace38f8f3 Update currency-formatter 2023-01-31 22:28:05 +01:00
Alf Sebastian Houge
63e330ffb0 Update outdated packages accoording to version locks 2023-01-31 22:07:26 +01:00
Alf Sebastian Houge
fc9796081e Remove yarn from project 2023-01-31 21:53:53 +01:00
Alf Sebastian Houge
440913af9c Merge pull request #26 from AlfHou/fix/clean-vscode-files
Remove .vscode specific files from being tracked
2023-01-30 22:46:57 +01:00
Alf Sebastian Houge
66d01afe6e Merge pull request #25 from AlfHou/helm
Add notes about Helm/Kubernetes
2023-01-30 22:39:39 +01:00
Alf Sebastian Houge
ad4a399dc8 Remove .vscode specific files 2023-01-30 22:18:36 +01:00
Jonathan Gazeley
2137bf7702 Add notes about Helm/Kubernetes 2023-01-30 21:15:41 +00:00
Alf Sebastian Houge
47bdf7b505 Merge pull request #24 from AlfHou/fix/translation-fixes
Fix 'engine size' field's label
2023-01-30 22:11:36 +01:00
Alf Sebastian Houge
669bffa955 Fix wrong translation for engine size field 2023-01-30 22:06:47 +01:00
Alf Sebastian Houge
05c5381a06 Remove ui .vscode directory 2023-01-30 22:03:23 +01:00
Alf Sebastian Houge
e623e3ad1a Ignore .vscode directory in git 2023-01-30 22:01:19 +01:00
Alf Sebastian Houge
c43a2f639a Merge pull request #23 from AlfHou/update-gitignore
Add vscode specific files to gitignore
2023-01-30 21:56:01 +01:00
Alf Sebastian Houge
e66e5b7724 Merge pull request #18 from solluh/master
Update de.json
2023-01-28 22:53:50 +01:00
Alf Sebastian Houge
adfd70fe98 Merge pull request #20 from AlfHou/feat/drivvo-import
Add importer for Drivvo
2023-01-28 22:51:32 +01:00
Alf Sebastian Houge
ebebcacdc9 Fix pricePerUnit parsing 2023-01-28 22:49:38 +01:00
Alf Sebastian Houge
3299c13181 Merge branch 'master' into feat/drivvo-import 2023-01-28 22:22:24 +01:00
Alf Sebastian Houge
2661f8ae36 Merge pull request #19 from AlfHou/feat/state_mileage_option
Add option in vehicle stats to choose what mileage metric to show
2023-01-28 22:20:21 +01:00
Alf Sebastian Houge
091cfdcc99 Merge branch 'master' into feat/state_mileage_option 2023-01-28 22:19:02 +01:00
solluh
9771dc4c25 Update de.json
Einige kleine Fehler korrigiert
2023-01-27 09:03:38 +01:00
Alf Sebastian Houge
8e894844a3 Merge pull request #13 from AlfHou/chore/docker-push
Publish new releases to docker repo
2023-01-26 23:11:32 +01:00
Alf Sebastian Houge
4a55879ad8 Update links in more info view in settings 2023-01-25 23:51:33 +01:00
Alf Sebastian Houge
9dab3d124d Point to this repo in ubuntu install docs 2023-01-25 23:43:12 +01:00
Alf Sebastian Houge
a89ca5e46a Update links in docker files to point to this repo 2023-01-25 23:42:43 +01:00
Alf Sebastian Houge
f96638d913 Update readme with the correct url's and remove some specifics from forked repo 2023-01-25 23:37:01 +01:00
Alf Sebastian Houge
08f2a3547e Rename docker secret variable 2023-01-25 23:37:01 +01:00
Alf Sebastian Houge
126aff7231 Update workflow action for publishing to new docker hub repo 2023-01-25 23:37:01 +01:00
Alf Sebastian Houge
ba276975f3 Merge pull request #5 from boerniee/i18n
Added translations
2023-01-25 23:02:17 +01:00
Alf Sebastian Houge
7d4b763e48 Merge branch 'master' into i18n 2023-01-25 23:00:25 +01:00
boerniee
ee964a630e Fixed another translations 2023-01-25 22:03:53 +01:00
boerniee
c588e34b2e translating fuelly import page 2023-01-25 21:52:44 +01:00
Bernhard Großer
6871a40380 added not translated texts, changed translations 2023-01-25 21:23:50 +01:00
Alf Sebastian Houge
0035897f21 Merge pull request #12 from AlfHou/bug/remove-npm-github-action
Remove Github Action for running npm tests
2023-01-24 23:08:19 +01:00
Alf Sebastian Houge
19680b1cc1 Remove the github action which ran npm tests 2023-01-24 22:59:59 +01:00
Alf Sebastian Houge
e6e90d9bef Merge pull request #9 from AlfHou/chore/update-package-lock
Update the package lock file
2023-01-22 23:09:37 +01:00
Alf Sebastian Houge
311ac7579a Update the package lock file 2023-01-22 23:05:40 +01:00
Alf Sebastian Houge
47810a8c88 Merge pull request #3 from AlfHou/add-vin
Add VIN field to vehicle data
2023-01-19 14:53:29 +01:00
alfhouge
f9d24bc7ef Fix indentation in db migration 2023-01-19 14:52:43 +01:00
Bernhard Großer
5aabeda6ba update axios version fixes #104 (#4) 2023-01-19 14:44:39 +01:00
boerniee
bb68c8c504 translate units 2023-01-13 23:51:40 +01:00
boerniee
961ec30065 set locale to browser locale 2023-01-13 23:51:32 +01:00
boerniee
0b450dc462 added translations
#80 #19
2023-01-13 23:51:19 +01:00
Alf Sebastian Houge
d3ce6920ad Merge branch 'master' into add-vin 2022-11-26 19:38:51 +01:00
Alf Sebastian Houge
afdfa31148 Add vscode specific files to gitignore 2022-11-26 19:24:44 +01:00
Alf Sebastian Houge
2d24c4b9e6 Merge pull request #1 from djjudas21/ci
Run Github actions on releases
2022-11-26 19:13:19 +01:00
Alf Sebastian Houge
c0db2c5c1e Calculate mileage based on mileageOption 2022-10-15 20:18:26 +02:00
Alf Sebastian Houge
2ecb113918 Add mileage options to frontend 2022-10-15 20:17:41 +02:00
Alf Sebastian Houge
966cac280f Add utils for converting imperial and metric 2022-10-15 20:17:19 +02:00
Akhil Gupta
84cba2c7f2 Merge pull request #74 from AlfHou/case-insensitive-emails 2022-07-25 12:01:51 +05:30
Akhil Gupta
1432499a90 Merge pull request #101 from heikok88/bugfix/wrongStatsSums 2022-07-25 11:59:56 +05:30
Eikou
d0704c8c6a Fixed issue #87: adapted start date of 'this_week' and 'this_year' stats 2022-07-22 15:26:17 +02:00
Akhil Gupta
b86795bcb6 Merge pull request #71 from AlfHou/contributing-instructions
Add contributing instructions
2022-07-12 17:23:30 +05:30
Akhil Gupta
1ccdce9ee3 Merge pull request #89 from AlfHou/chore/fix-error-messages
Fix warnings reported by go-staticcheck
2022-07-12 17:23:07 +05:30
Akhil Gupta
5cfaf8c933 Merge branch 'master' of https://github.com/akhilrex/hammond 2022-07-06 11:00:13 +05:30
Akhil Gupta
987f035198 fix issue #93 2022-07-06 11:00:09 +05:30
Jonathan Gazeley
ab94997dd6 Add basic tests
Run release pipeline on tags
2022-05-18 21:50:25 +01:00
Alf Sebastian Houge
0b715ef840 Fix warnings generated by go-staticcheck 2022-04-28 17:52:57 +02:00
Alf Sebastian Houge
c00c6bc776 Map VIN number to db model from request model when creating vehicle 2022-04-28 17:36:13 +02:00
Akhil Gupta
a5d4dface8 Merge pull request #85 from AlfHou/bug/strip-login-whitespace 2022-04-26 16:32:10 +05:30
Akhil Gupta
7cb9a43dfe try disable cache 2022-04-26 14:26:21 +05:30
Akhil Gupta
05bb22fe4e update a couple of packages and bump version 2022-04-26 13:34:26 +05:30
Akhil Gupta
69352af906 build fixed 2022-04-26 12:59:31 +05:30
Akhil Gupta
7a8916c9cd fix file 2022-04-26 12:27:01 +05:30
Akhil Gupta
e471e80617 try fix breaking build 2022-04-26 12:25:33 +05:30
Akhil Gupta
1ee032b664 Merge pull request #86 from AlfHou/feat/currency-search
Make currency field searchable
2022-04-26 11:58:19 +05:30
Akhil Gupta
cea2566e2a Merge branch 'master' of https://github.com/akhilrex/hammond 2022-04-26 11:54:59 +05:30
Akhil Gupta
dcb58bbbdb some alerting code 2022-04-26 11:54:55 +05:30
Akhil Gupta
24105dbaaf Merge pull request #59 from meichthys/patch-1
remove whitespace from JWT_SECRET
2022-04-25 16:42:53 +05:30
Akhil Gupta
e3846634b5 Merge pull request #75 from AlfHou/icon-mobile
Add icon for mobile homescreens
2022-04-25 16:42:25 +05:30
Akhil Gupta
fd52c23636 Merge pull request #77 from AlfHou/mileage_on_odometer 2022-04-25 16:41:11 +05:30
Alf Sebastian Houge
43d1ca0c66 Tag login email field as email type 2022-04-17 18:53:15 +02:00
Alf Sebastian Houge
fb742f19a7 Change currency field from select to autocomplete 2022-04-17 18:52:19 +02:00
Alf Sebastian Houge
2749707546 Change logic in how indexes of sections in csv work
Change the logic in how indexes of the different sections in drivvos csv work
2022-04-06 17:56:58 +02:00
Alf Sebastian Houge
f1bf36bcb9 Separate imports into provider specific files 2022-04-06 17:56:58 +02:00
Alf Sebastian Houge
3322e2f6bd Don't redirect from import page on erro 2022-04-06 17:56:58 +02:00
Alf Sebastian Houge
9ef929dbd5 Redirect to home after importing 2022-04-06 17:56:58 +02:00
Alf Sebastian Houge
dc33aaad49 Add option for not importing location 2022-04-06 17:56:58 +02:00
Alf Sebastian Houge
15cf09f326 Add notice about what fields aren't imported from drivvo 2022-04-06 17:56:58 +02:00
Alf Sebastian Houge
1e099ec8b6 Create db.Expense and db.Fillup in one place
Create Expense and Fillup in one place instead of throughout function
2022-04-06 17:56:58 +02:00
Alf Sebastian Houge
e8f7815d8d Select vehicle when importing drivvo csv.
Select what vehicle to import for on frontend. Actually import the data to the db
2022-04-06 17:56:58 +02:00
Alf Sebastian Houge
bfaebf17d0 Add functions for parsing drivvo CSVs. 2022-04-06 17:56:58 +02:00
Alf Sebastian Houge
20a1421576 Calculate mileage on odometer order instead of time 2022-03-15 14:26:46 +01:00
Alf Sebastian Houge
8410674841 Add vin field vehicle data 2022-03-08 13:49:11 +01:00
Alf Sebastian Houge
74e52c3e87 Change favicon to match mobile icon 2022-03-07 16:53:47 +01:00
Alf Sebastian Houge
1857bb0518 Change icon to gas pump icon instead of text 2022-03-07 10:41:28 +01:00
Alf Sebastian Houge
a729b5eb12 Add icon for mobile homescreens 2022-03-07 00:46:42 +01:00
Alf Sebastian Houge
d9a99d432c Make emails case insensitive 2022-03-07 00:17:35 +01:00
Alf Sebastian Houge
d314ed4a16 Add vscode specific files to gitignore 2022-03-05 19:21:10 +01:00
MeIchthys
04f45fe385 remove whitespace from JWT_SECRET
When deploying with the whitespace around the `=`, docker complains.
2022-01-20 23:55:39 -05:00
Akhil Gupta
fca2c3e7fa start alerts 2021-09-03 15:46:39 +05:30
86 changed files with 45515 additions and 20107 deletions

View File

@@ -1,57 +1,49 @@
name: ci
name: Build docker image
on:
push:
branches: master
release:
types: [published]
jobs:
multi:
runs-on: ubuntu-latest
steps:
-
name: Checkout
- name: Checkout
uses: actions/checkout@v2
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
-
name: Set up build cache
uses: actions/cache@v2
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
-
name: Login to DockerHub
uses: docker/login-action@v1
platforms: linux/amd64,linux/arm64,linux/arm/v7
- name: Available platforms
run: echo ${{ steps.qemu.outputs.platforms }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Parse the git tag
id: get_tag
run: echo ::set-output name=TAG::$(echo $GITHUB_REF | cut -d / -f 3)
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Login to GitHub
password: ${{ secrets.DOCKER_TOKEN }}
- name: Login to GitHub
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.CR_PAT }}
-
name: Build and push
uses: docker/build-push-action@v2
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
#platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
#platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
# cache-from: type=local,src=/tmp/.buildx-cache
# cache-to: type=local,dest=/tmp/.buildx-cache
tags: |
akhilrex/hammond:latest
akhilrex/hammond:1.0.0
ghcr.io/akhilrex/hammond:latest
ghcr.io/akhilrex/hammond:1.0.0
alfhou/hammond:latest
alfhou/hammond:${{ steps.get_tag.outputs.TAG }}
ghcr.io/alfhou/hammond:latest
ghcr.io/alfhou/hammond:${{ steps.get_tag.outputs.TAG }}

16
.github/workflows/test-go.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
on: [push, pull_request]
name: Test server
jobs:
test:
strategy:
matrix:
go-version: [1.17.x, 1.18.x]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}
- uses: actions/checkout@v3
- run: go test ./...
working-directory: server

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# Don't track .vscode directory
.vscode
!.vscode/launch.json

View File

@@ -1,4 +1,4 @@
ARG GO_VERSION=1.16.2
ARG GO_VERSION=1.20.6
FROM golang:${GO_VERSION}-alpine AS builder
RUN apk update && apk add alpine-sdk git && rm -rf /var/cache/apk/*
RUN mkdir -p /api
@@ -9,16 +9,17 @@ RUN go mod download
COPY ./server .
RUN go build -o ./app ./main.go
FROM node:latest as build-stage
FROM node:16-alpine as build-stage
WORKDIR /app
COPY ./ui/package*.json ./
RUN apk add --no-cache autoconf automake build-base
RUN npm install
COPY ./ui .
RUN npm run build
FROM alpine:latest
LABEL org.opencontainers.image.source="https://github.com/akhilrex/hammond"
LABEL org.opencontainers.image.source="https://github.com/alfhou/hammond"
ENV CONFIG=/config
ENV DATA=/assets
ENV UID=998
@@ -36,4 +37,4 @@ COPY --from=builder /api/app .
#COPY dist ./dist
COPY --from=build-stage /app/dist ./dist
EXPOSE 3000
ENTRYPOINT ["./app"]
ENTRYPOINT ["./app"]

View File

@@ -1,26 +1,15 @@
[![Contributors][contributors-shield]][contributors-url] [![Forks][forks-shield]][forks-url] [![Stargazers][stars-shield]][stars-url] [![Issues][issues-shield]][issues-url] [![MIT License][license-shield]][license-url] [![LinkedIn][linkedin-shield]][linkedin-url]
<!-- PROJECT LOGO -->
<br />
<p align="center">
<!-- <a href="https://github.com/akhilrex/hammond">
<img src="images/logo.png" alt="Logo" width="80" height="80">
</a> -->
<h1 align="center" style="margin-bottom:0">Hammond</h1>
<p align="center">Current Version - 2021.09.20</p>
<p align="center">
A self-hosted vehicle expense tracking system with support for multiple users.
<br />
<a href="https://github.com/akhilrex/hammond"><strong>Explore the docs »</strong></a>
<a href="https://github.com/AlfHou/hammond"><strong>Explore the docs »</strong></a>
<br />
<br />
<!-- <a href="https://github.com/akhilrex/hammond">View Demo</a>
· -->
<a href="https://github.com/akhilrex/hammond/issues">Report Bug</a>
<a href="https://github.com/AlfHou/hammond/issues">Report Bug</a>
·
<a href="https://github.com/akhilrex/hammond/issues">Request Feature</a>
<a href="https://github.com/AlfHou/hammond/issues">Request Feature</a>
·
<a href="Screenshots.md">Screenshots</a>
</p>
@@ -44,18 +33,22 @@
## About The Project
Hammond is a self hosted vehicle management system to track fuel and other expenses related to all of your vehicles. It supports multiple users sharing multiple vehicles. It is the logical successor to Clarkson which has not been updated for quite some time now.
Hammond is a self hosted vehicle management system to track fuel and other
expenses related to all of your vehicles.
It supports multiple users sharing multiple vehicles.
It is the logical successor to Clarkson which has not been updated for quite some time now.
This repo is again a fork of akhilrex's great [project](https://github.com/akhilrex/hammond).
_Developers Note: This project is under active development which means I release new updates very frequently. It is recommended that you use something like [watchtower](https://github.com/containrrr/watchtower) which will automatically update your containers whenever I release a new version or periodically rebuild the container with the latest image manually._
__Also check out my other self-hosted, open-source solution - [Podgrab](https://github.com/akhilrex/podgrab) - Podcast download and archive manager and player.__
### Motivation and Developer Notes
I was looking for a fuel tracking system and stumbled upon Clarkson. Although it did most of what I needed it has not been updated for quite a lot of time. Since I had some bandwidth available as my previous open source project [Podgrab](http://github.com/akhilrex/podgrab) had become quite stable now, my first thought was to contribute to the Clarkson project only. I soon realized that the architecture that Clarkson had used was not really be that extensible now and would warrant a complete rewrite only. So I decided to build Hammond - The successor to Clarkson.
As mentioned, this project is a fork of
akhilrex's [project](https://github.com/akhilrex/hammond) which is no longer active.
To prevent the same from happeing to this project, we are seeking to add more
maintainers/collaborators who have access to merge PRs.
The current version of Hammond is written using GO for backend and Vuejs for the front end. Originally I had thought of using the same tech stack for both frontend and the backend so that it became easier for users and other developers to use, deploy and contribute. Which is why the first version of Hammond has a NestJS backend complete with all the bells and whistles (GraphQL, Prisma and what nots). But I eventually decided to rebuild the backend in GO just to keep the container size small. No matter how much you can optimize the sheer size of the node_modules will always add bulk to your containers. I host all my tools on my Raspberry Pi. It only makes sense to keep the container size as small as possible.
We are trying our best to update with new features and feedback is very welcome.
Also I had initially thought of a 2 container approach (1 for backend and 1 for the frontend) so that they can be independently maintained and updated. I eventually decided against this idea for the sake of simplicity. Although it is safe to assume that most self-hosters are fairly tech capable it still is much better to have a single container that you can fire and forget.
The project is written using Go for the backend and Vuejs for the front end.
![Product Name Screen Shot][product-screenshot] [More Screenshots](Screenshots.md)
@@ -79,7 +72,7 @@ Also I had initially thought of a 2 container approach (1 for backend and 1 for
- Save attachment against vehicles
- Quick Entries (take a photo of a receipt or pump screen to make entry later)
- Vehicle level and overall reporting
- Import from Fuelly (more apps coming soon)
- Import from Fuelly and Drivvo
## Installation
@@ -90,24 +83,25 @@ The easiest way to run Hammond is to run it as a docker container.
Simple setup without mounted volumes (for testing and evaluation)
```sh
docker run -d -p 3000:3000 --name=hammond akhilrex/hammond
docker run -d -p 3000:3000 --name=hammond alfhou/hammond
```
Binding local volumes to the container
```sh
docker run -d -p 3000:3000 --name=hammond -v "/host/path/to/assets:/assets" -v "/host/path/to/config:/config" akhilrex/hammond
docker run -d -p 3000:3000 --name=hammond -v "/host/path/to/assets:/assets" -v "/host/path/to/config:/config" alfhou/hammond
```
### Using Docker-Compose
Modify the docker compose file provided [here](https://github.com/akhilrex/hammond/blob/master/docker-compose.yml) to update the volume and port binding and run the following command
Modify the docker compose file provided [here](https://github.com/alfhou/hammond/blob/master/docker-compose.yml)
to update the volume and port binding and run the following command
```yaml
version: '2.1'
services:
hammond:
image: akhilrex/hammond
image: alfhou/hammond
container_name: hammond
volumes:
- /path/to/config:/config
@@ -121,9 +115,27 @@ services:
docker-compose up -d
```
### Install on Kubernetes
You can install Hammond on Kubernetes by using Helm. The
[Helm chart for Hammond](https://github.com/djjudas21/charts/tree/main/charts/hammond)
is maintained by djjudas21.
Check out the default [`values.yaml`](https://github.com/djjudas21/charts/blob/main/charts/hammond/values.yaml)
to see what you can override.
```console
helm repo add djjudas21 https://djjudas21.github.io/charts/
helm repo update djjudas21
helm install djjudas21/hammond
```
### Build from Source / Ubuntu Installation
Although personally I feel that using the docker container is the best way of using and enjoying something like hammond, a lot of people in the community are still not comfortable with using Docker and wanted to host it natively on their Linux servers. Follow the link below to get a guide on how to build hammond from source.
Although personally I feel that using the docker container is the best way of using
and enjoying something like hammond, a lot of people in the community are still not
comfortable with using Docker and wanted to host it natively on their Linux servers.
Follow the link below to get a guide on how to build hammond from source.
[Build from source / Ubuntu Guide](docs/ubuntu-install.md)
@@ -197,25 +209,6 @@ Distributed under the GPL-3.0 License. See `LICENSE` for more information.
## Contact
Akhil Gupta - [@akhilrex](https://twitter.com/akhilrex)
Project Link: [https://github.com/AlfHou/hammond](https://github.com/AlfHou/hammond)
Project Link: [https://github.com/akhilrex/hammond](https://github.com/akhilrex/hammond)
<a href="https://www.buymeacoffee.com/akhilrex" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="width: 217px !important;height: 60px !important;" ></a>
<!-- MARKDOWN LINKS & IMAGES -->
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
[contributors-shield]: https://img.shields.io/github/contributors/akhilrex/hammond.svg?style=flat-square
[contributors-url]: https://github.com/akhilrex/hammond/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/akhilrex/hammond.svg?style=flat-square
[forks-url]: https://github.com/akhilrex/hammond/network/members
[stars-shield]: https://img.shields.io/github/stars/akhilrex/hammond.svg?style=flat-square
[stars-url]: https://github.com/akhilrex/hammond/stargazers
[issues-shield]: https://img.shields.io/github/issues/akhilrex/hammond.svg?style=flat-square
[issues-url]: https://github.com/akhilrex/hammond/issues
[license-shield]: https://img.shields.io/github/license/akhilrex/hammond.svg?style=flat-square
[license-url]: https://github.com/akhilrex/hammond/blob/master/LICENSE
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555
[linkedin-url]: https://linkedin.com/in/akhilrex
[product-screenshot]: images/screenshot.jpg

View File

@@ -1,10 +1,10 @@
version: "2.1"
services:
hammond:
image: akhilrex/hammond
image: alfhou/hammond
container_name: hammond
environment:
- JWT_SECRET = somethingverystrong
- JWT_SECRET=somethingverystrong
volumes:
- /path/to/config:/config
- /path/to/data:/assets

View File

@@ -26,7 +26,7 @@ Following steps will only work if Go and Node are installed and configured prope
## Clone from Git
``` bash
git clone --depth 1 https://github.com/akhilrex/hammond
git clone --depth 1 https://github.com/alfhou/hammond
```
## Build and Copy dependencies
@@ -110,7 +110,7 @@ sudo systemctl stop hammond.service
## Clone from Git
``` bash
git clone --depth 1 https://github.com/akhilrex/hammond
git clone --depth 1 https://github.com/alfhou/hammond
```
## Build and Copy dependencies

7
server/.gitignore vendored
View File

@@ -12,10 +12,15 @@
*.out
*.db
# MS VSCode
.vscode
!.vscode/launch.json
__debug_bin
# Dependency directories (remove the comment below to include it)
# vendor/
assets/*
keys/*
backups/*
nodemon.json
dist/*
dist/*

View File

@@ -16,7 +16,7 @@ RUN go build -o ./app ./main.go
FROM alpine:latest
LABEL org.opencontainers.image.source="https://github.com/akhilrex/hammond"
LABEL org.opencontainers.image.source="https://github.com/alfhou/hammond"
ENV CONFIG=/config
ENV DATA=/assets
@@ -38,4 +38,4 @@ COPY dist ./dist
EXPOSE 3000
ENTRYPOINT ["./app"]
ENTRYPOINT ["./app"]

View File

@@ -7,7 +7,8 @@ import (
"os"
"time"
"github.com/akhilrex/hammond/db"
"hammond/db"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
@@ -25,6 +26,33 @@ func RandString(n int) string {
return string(b)
}
// A helper to convert from litres to gallon
func LitreToGallon(litres float32) float32 {
gallonConversionFactor := 0.21997
return litres * float32(gallonConversionFactor);
}
// A helper to convert from gallon to litres
func GallonToLitre(gallons float32) float32 {
litreConversionFactor := 3.785412
return gallons * float32(litreConversionFactor);
}
// A helper to convert from km to miles
func KmToMiles(km float32) float32 {
kmConversionFactor := 0.62137119
return km * float32(kmConversionFactor);
}
// A helper to convert from miles to km
func MilesToKm(miles float32) float32 {
milesConversionFactor := 1.609344
return miles * float32(milesConversionFactor);
}
// A Util function to generate jwt_token which can be used in the request header
func GenToken(id string, role db.Role) (string, string) {
jwt_token := jwt.New(jwt.GetSigningMethod("HS256"))

View File

@@ -5,11 +5,13 @@ import (
"fmt"
"net/http"
"os"
"strings"
"hammond/common"
"hammond/db"
"hammond/models"
"hammond/service"
"github.com/akhilrex/hammond/common"
"github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/models"
"github.com/akhilrex/hammond/service"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
)
@@ -91,20 +93,20 @@ func userLogin(c *gin.Context) {
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
return
}
user, err := db.FindOneUser(&db.User{Email: loginRequest.Email})
user, err := db.FindOneUser(&db.User{Email: strings.ToLower(loginRequest.Email)})
if err != nil {
c.JSON(http.StatusForbidden, common.NewError("login", errors.New("Not Registered email or invalid password")))
c.JSON(http.StatusForbidden, common.NewError("login", errors.New("not Registered email or invalid password")))
return
}
if user.CheckPassword(loginRequest.Password) != nil {
c.JSON(http.StatusForbidden, common.NewError("login", errors.New("Not Registered email or invalid password")))
c.JSON(http.StatusForbidden, common.NewError("login", errors.New("not Registered email or invalid password")))
return
}
if user.IsDisabled {
c.JSON(http.StatusForbidden, common.NewError("login", errors.New("Your user has been disabled by the admin. Please contact them to get it re-enabled.")))
c.JSON(http.StatusForbidden, common.NewError("login", errors.New("your user has been disabled by the admin. Please contact them to get it re-enabled")))
return
}
UpdateContextUserModel(c, user.ID)
@@ -114,7 +116,7 @@ func userLogin(c *gin.Context) {
Email: user.Email,
Token: token,
RefreshToken: refreshToken,
Role: user.RoleDetail().Long,
Role: user.RoleDetail().Key,
}
c.JSON(http.StatusOK, response)
}
@@ -148,7 +150,7 @@ func refresh(c *gin.Context) {
Email: user.Email,
Token: token,
RefreshToken: refreshToken,
Role: user.RoleDetail().Long,
Role: user.RoleDetail().Key,
}
c.JSON(http.StatusOK, response)
} else {
@@ -170,16 +172,16 @@ func changePassword(c *gin.Context) {
user, err := service.GetUserById(c.GetString("userId"))
if err != nil {
c.JSON(http.StatusForbidden, common.NewError("changePassword", errors.New("Not Registered email or invalid password")))
c.JSON(http.StatusForbidden, common.NewError("changePassword", errors.New("not Registered email or invalid password")))
return
}
if user.CheckPassword(request.OldPassword) != nil {
c.JSON(http.StatusForbidden, common.NewError("changePassword", errors.New("Incorrect old password")))
c.JSON(http.StatusForbidden, common.NewError("changePassword", errors.New("incorrect old password")))
return
}
user.SetPassword(request.NewPassword)
success, err := service.UpdatePassword(user.ID, request.NewPassword)
success, _ := service.UpdatePassword(user.ID, request.NewPassword)
c.JSON(http.StatusOK, success)
}

View File

@@ -5,10 +5,11 @@ import (
"net/http"
"os"
"github.com/akhilrex/hammond/common"
"github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/models"
"github.com/akhilrex/hammond/service"
"hammond/common"
"hammond/db"
"hammond/models"
"hammond/service"
"github.com/gin-gonic/gin"
)

View File

@@ -2,13 +2,18 @@ package controllers
import (
"net/http"
"strconv"
"hammond/models"
"hammond/service"
"github.com/akhilrex/hammond/service"
"github.com/gin-gonic/gin"
)
func RegisteImportController(router *gin.RouterGroup) {
router.POST("/import/fuelly", fuellyImport)
router.POST("/import/drivvo", drivvoImport)
router.POST("/import/generic", genericImport)
}
func fuellyImport(c *gin.Context) {
@@ -24,3 +29,46 @@ func fuellyImport(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{})
}
func drivvoImport(c *gin.Context) {
bytes, err := getFileBytes(c, "file")
if err != nil {
c.JSON(http.StatusUnprocessableEntity, err)
return
}
vehicleId := c.PostForm("vehicleID")
if vehicleId == "" {
c.JSON(http.StatusUnprocessableEntity, "Missing Vehicle ID")
return
}
importLocation, err := strconv.ParseBool(c.PostForm("importLocation"))
if err != nil {
c.JSON(http.StatusUnprocessableEntity, "Please include importLocation option.")
return
}
errors := service.DrivvoImport(bytes, c.MustGet("userId").(string), vehicleId, importLocation)
if len(errors) > 0 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": errors})
return
}
c.JSON(http.StatusOK, gin.H{})
}
func genericImport(c *gin.Context) {
var json models.ImportData
if err := c.ShouldBindJSON(&json); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if json.VehicleId == "" {
c.JSON(http.StatusUnprocessableEntity, "Missing Vehicle ID")
return
}
errors := service.GenericImport(json, c.MustGet("userId").(string))
if len(errors) > 0 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": errors})
return
}
c.JSON(http.StatusOK, gin.H{})
}

View File

@@ -3,10 +3,11 @@ package controllers
import (
"net/http"
"github.com/akhilrex/hammond/common"
"github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/models"
"github.com/akhilrex/hammond/service"
"hammond/common"
"hammond/db"
"hammond/models"
"hammond/service"
"github.com/gin-gonic/gin"
)

View File

@@ -5,7 +5,8 @@ import (
"os"
"strings"
"github.com/akhilrex/hammond/db"
"hammond/db"
"github.com/dgrijalva/jwt-go"
"github.com/dgrijalva/jwt-go/request"
"github.com/gin-gonic/gin"
@@ -23,8 +24,8 @@ func stripBearerPrefixFromTokenString(tok string) (string, error) {
// Extract token from Authorization header
// Uses PostExtractionFilter to strip "TOKEN " prefix from header
var AuthorizationHeaderExtractor = &request.PostExtractionFilter{
request.HeaderExtractor{"Authorization"},
stripBearerPrefixFromTokenString,
Extractor: request.HeaderExtractor{"Authorization"},
Filter: stripBearerPrefixFromTokenString,
}
// Extractor for OAuth2 access tokens. Looks in 'Authorization'

View File

@@ -3,9 +3,10 @@ package controllers
import (
"net/http"
"github.com/akhilrex/hammond/common"
"github.com/akhilrex/hammond/models"
"github.com/akhilrex/hammond/service"
"hammond/common"
"hammond/models"
"hammond/service"
"github.com/gin-gonic/gin"
)
@@ -25,7 +26,7 @@ func getMileageForVehicle(c *gin.Context) {
return
}
fillups, err := service.GetMileageByVehicleId(searchByIdQuery.Id, model.Since)
fillups, err := service.GetMileageByVehicleId(searchByIdQuery.Id, model.Since, model.MileageOption)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewError("getMileageForVehicle", err))
return

View File

@@ -4,10 +4,11 @@ import (
"fmt"
"net/http"
"github.com/akhilrex/hammond/common"
"github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/models"
"github.com/akhilrex/hammond/service"
"hammond/common"
"hammond/db"
"hammond/models"
"hammond/service"
"github.com/gin-gonic/gin"
)
@@ -51,7 +52,7 @@ func migrate(c *gin.Context) {
canMigrate, _, _ := db.CanMigrate(request.Url)
if !canMigrate {
c.JSON(http.StatusBadRequest, fmt.Errorf("cannot migrate database. please check connection string."))
c.JSON(http.StatusBadRequest, fmt.Errorf("cannot migrate database. please check connection string"))
return
}

View File

@@ -3,10 +3,11 @@ package controllers
import (
"net/http"
"github.com/akhilrex/hammond/common"
"github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/models"
"github.com/akhilrex/hammond/service"
"hammond/common"
"hammond/db"
"hammond/models"
"hammond/service"
"github.com/gin-gonic/gin"
)

View File

@@ -4,9 +4,10 @@ import (
"errors"
"net/http"
"github.com/akhilrex/hammond/common"
"github.com/akhilrex/hammond/models"
"github.com/akhilrex/hammond/service"
"hammond/common"
"hammond/models"
"hammond/service"
"github.com/gin-gonic/gin"
)
@@ -397,7 +398,7 @@ func deleteVehicle(c *gin.Context) {
return
}
if !canDelete {
c.JSON(http.StatusUnprocessableEntity, common.NewError("shareVehicle", errors.New("You are not allowed to delete this vehicle.")))
c.JSON(http.StatusUnprocessableEntity, common.NewError("shareVehicle", errors.New("you are not allowed to delete this vehicle")))
return
}
err = service.DeleteVehicle(searchByIdQuery.Id)

View File

@@ -60,6 +60,7 @@ type Vehicle struct {
Base
Nickname string `json:"nickname"`
Registration string `json:"registration"`
VIN string `json:"vin"`
Make string `json:"make"`
Model string `json:"model"`
YearOfManufacture int `json:"yearOfManufacture"`
@@ -195,3 +196,50 @@ type VehicleAttachment struct {
VehicleID string `gorm:"primaryKey" json:"vehicleId"`
Title string `json:"title"`
}
type VehicleAlert struct {
Base
VehicleID string `json:"vehicleId"`
Vehicle Vehicle `json:"-"`
UserID string `json:"userId"`
User User `json:"user"`
Title string `json:"title"`
Comments string `json:"comments"`
StartDate time.Time `json:"date"`
StartOdoReading int `json:"startOdoReading"`
DistanceUnit DistanceUnit `json:"distanceUnit"`
AlertFrequency AlertFrequency `json:"alertFrequency"`
OdoFrequency int `json:"odoFrequency"`
DayFrequency int `json:"dayFrequency"`
AlertAllUsers bool `json:"alertAllUsers"`
IsActive bool `json:"isActive"`
EndDate *time.Time `json:"endDate"`
AlertType AlertType `json:"alertType"`
}
type AlertOccurance struct {
Base
VehicleID string `json:"vehicleId"`
Vehicle Vehicle `json:"-"`
VehicleAlertID string `json:"vehicleAlertId"`
VehicleAlert VehicleAlert `json:"-"`
UserID string `json:"userId"`
User User `json:"-"`
OdoReading int `json:"odoReading"`
Date *time.Time `json:"date"`
ProcessDate *time.Time `json:"processDate"`
AlertProcessType AlertType `json:"alertProcessType"`
CompleteDate *time.Time `json:"completeDate"`
}
type Notification struct {
Base
Title string `json:"title"`
Content string `json:"content"`
UserID string `json:"userId"`
VehicleID string `json:"vehicleId"`
User User `json:"-"`
Date time.Time `json:"date"`
ReadDate *time.Time `json:"readDate"`
ParentID string `json:"parentId"`
ParentType string `json:"parentType"`
}

View File

@@ -117,7 +117,7 @@ func UnshareVehicle(vehicleId, userId string) error {
return nil
}
if mapping.IsOwner {
return fmt.Errorf("Cannot unshare owner")
return fmt.Errorf("cannot unshare owner")
}
result := DB.Where("id=?", mapping.ID).Delete(&UserVehicle{})
return result.Error
@@ -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 GetLatestFillupsByVehicleId(id string) (*Fillup, error) {
var obj Fillup
result := DB.Preload(clause.Associations).Order("date desc").First(&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)
@@ -190,6 +195,11 @@ func GetExpensesByVehicleId(id string) (*[]Expense, error) {
result := DB.Preload(clause.Associations).Order("date desc").Find(&obj, &Expense{VehicleID: id})
return &obj, result.Error
}
func GetLatestExpenseByVehicleId(id string) (*Expense, error) {
var obj Expense
result := DB.Preload(clause.Associations).Order("date desc").First(&obj, &Expense{VehicleID: id})
return &obj, result.Error
}
func GetExpenseById(id string) (*Expense, error) {
var obj Expense
result := DB.Preload(clause.Associations).First(&obj, "id=?", id)
@@ -271,6 +281,29 @@ func GetVehicleAttachments(vehicleId string) (*[]Attachment, error) {
}
return &attachments, nil
}
func GeAlertById(id string) (*VehicleAlert, error) {
var alert VehicleAlert
result := DB.Preload(clause.Associations).First(&alert, "id=?", id)
return &alert, result.Error
}
func GetAlertOccurenceByAlertId(id string) (*[]AlertOccurance, error) {
var alertOccurance []AlertOccurance
result := DB.Preload(clause.Associations).Order("created_at desc").Find(&alertOccurance, "vehicle_alert_id=?", id)
return &alertOccurance, result.Error
}
func GetUnprocessedAlertOccurances() (*[]AlertOccurance, error) {
var alertOccurance []AlertOccurance
result := DB.Preload(clause.Associations).Order("created_at desc").Find(&alertOccurance, "process_date is NULL")
return &alertOccurance, result.Error
}
func MarkAlertOccuranceAsProcessed(id string, alertProcessType AlertType, date time.Time) error {
tx := DB.Debug().Model(&AlertOccurance{}).Where("id= ?", id).
Update("alert_process_type", alertProcessType).
Update("process_date", date)
return tx.Error
}
func UpdateSettings(setting *Setting) error {
tx := DB.Save(&setting)
@@ -332,8 +365,7 @@ func UnlockMissedJobs() {
if (job.Date == time.Time{}) {
continue
}
var duration time.Duration
duration = time.Duration(job.Duration)
var duration = time.Duration(job.Duration)
d := job.Date.Add(time.Minute * duration)
if d.Before(time.Now()) {
fmt.Println(job.Name + " is unlocked")

View File

@@ -36,76 +36,74 @@ const (
USER
)
type AlertFrequency int
const (
ONETIME AlertFrequency = iota
RECURRING
)
type AlertType int
const (
DISTANCE AlertType = iota
TIME
BOTH
)
type EnumDetail struct {
Short string `json:"short"`
Long string `json:"long"`
Key string `json:"key"`
}
var FuelUnitDetails map[FuelUnit]EnumDetail = map[FuelUnit]EnumDetail{
LITRE: {
Short: "Lt",
Long: "Litre",
Key: "litre",
},
GALLON: {
Short: "Gal",
Long: "Gallon",
Key: "gallon",
}, KILOGRAM: {
Short: "Kg",
Long: "Kilogram",
Key: "kilogram",
}, KILOWATT_HOUR: {
Short: "KwH",
Long: "Kilowatt Hour",
Key: "kilowatthour",
}, US_GALLON: {
Short: "US Gal",
Long: "US Gallon",
Key: "usgallon",
},
MINUTE: {
Short: "Mins",
Long: "Minutes",
Key: "minutes",
},
}
var FuelTypeDetails map[FuelType]EnumDetail = map[FuelType]EnumDetail{
PETROL: {
Short: "Petrol",
Long: "Petrol",
Key: "petrol",
},
DIESEL: {
Short: "Diesel",
Long: "Diesel",
Key: "diesel",
}, CNG: {
Short: "CNG",
Long: "CNG",
Key: "cng",
}, LPG: {
Short: "LPG",
Long: "LPG",
Key: "lpg",
}, ELECTRIC: {
Short: "Electric",
Long: "Electric",
Key: "electric",
}, ETHANOL: {
Short: "Ethanol",
Long: "Ethanol",
Key: "ethanol",
},
}
var DistanceUnitDetails map[DistanceUnit]EnumDetail = map[DistanceUnit]EnumDetail{
KILOMETERS: {
Short: "Km",
Long: "Kilometers",
Key: "kilometers",
},
MILES: {
Short: "Mi",
Long: "Miles",
Key: "miles",
},
}
var RoleDetails map[Role]EnumDetail = map[Role]EnumDetail{
ADMIN: {
Short: "Admin",
Long: "ADMIN",
Key: "ADMIN",
},
USER: {
Short: "User",
Long: "USER",
Key: "USER",
},
}

View File

@@ -18,6 +18,15 @@ var migrations = []localMigration{
Name: "2021_06_24_04_42_SetUserDisabledFalse",
Query: "update users set is_disabled=0",
},
{
Name: "2021_02_07_00_09_LowerCaseEmails",
Query: "update users set email=lower(email)",
},
{
Name: "2022_03_08_13_16_AddVIN",
Query: "ALTER TABLE vehicles ADD COLUMN vin text",
},
}
func RunMigrations() {

View File

@@ -1,4 +1,4 @@
module github.com/akhilrex/hammond
module hammond
go 1.16

View File

@@ -5,9 +5,10 @@ import (
"log"
"os"
"github.com/akhilrex/hammond/controllers"
"github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/service"
"hammond/controllers"
"hammond/db"
"hammond/service"
"github.com/gin-contrib/location"
"github.com/gin-gonic/contrib/static"
"github.com/gin-gonic/gin"

21
server/models/alert.go Normal file
View File

@@ -0,0 +1,21 @@
package models
import (
"time"
"hammond/db"
)
type CreateAlertModel struct {
Comments string `json:"comments"`
Title string `json:"title"`
StartDate time.Time `json:"date"`
StartOdoReading int `json:"startOdoReading"`
DistanceUnit *db.DistanceUnit `json:"distanceUnit"`
AlertFrequency *db.AlertFrequency `json:"alertFrequency"`
OdoFrequency int `json:"odoFrequency"`
DayFrequency int `json:"dayFrequency"`
AlertAllUsers bool `json:"alertAllUsers"`
IsActive bool `json:"isActive"`
AlertType *db.AlertType `json:"alertType"`
}

View File

@@ -1,6 +1,6 @@
package models
import "github.com/akhilrex/hammond/db"
import "hammond/db"
type LoginResponse struct {
Name string `json:"name"`

22
server/models/import.go Normal file
View File

@@ -0,0 +1,22 @@
package models
type ImportData struct {
Data []ImportFillup `json:"data" binding:"required"`
VehicleId string `json:"vehicleId" binding:"required"`
TimeZone string `json:"timezone" binding:"required"`
}
type ImportFillup struct {
VehicleID string `json:"vehicleId"`
FuelQuantity float32 `json:"fuelQuantity"`
PerUnitPrice float32 `json:"perUnitPrice"`
TotalAmount float32 `json:"totalAmount"`
OdoReading int `json:"odoReading"`
IsTankFull *bool `json:"isTankFull"`
HasMissedFillup *bool `json:"hasMissedFillup"`
Comments string `json:"comments"`
FillingStation string `json:"fillingStation"`
UserID string `json:"userId"`
Date string `json:"date"`
FuelSubType string `json:"fuelSubType"`
}

View File

@@ -1,6 +1,6 @@
package models
import "github.com/akhilrex/hammond/db"
import "hammond/db"
type UpdateSettingModel struct {
Currency string `json:"currency" form:"currency" query:"currency"`

View File

@@ -4,7 +4,7 @@ import (
"encoding/json"
"time"
"github.com/akhilrex/hammond/db"
"hammond/db"
)
type MileageModel struct {
@@ -14,7 +14,7 @@ type MileageModel struct {
FuelQuantity float32 `form:"fuelQuantity" json:"fuelQuantity" binding:"required"`
PerUnitPrice float32 `form:"perUnitPrice" json:"perUnitPrice" binding:"required"`
Currency string `json:"currency"`
DistanceUnit db.DistanceUnit `form:"distanceUnit" json:"distanceUnit"`
Mileage float32 `form:"mileage" json:"mileage" binding:"mileage"`
CostPerMile float32 `form:"costPerMile" json:"costPerMile" binding:"costPerMile"`
OdoReading int `form:"odoReading" json:"odoReading" binding:"odoReading"`
@@ -35,4 +35,5 @@ func (b *MileageModel) MarshalJSON() ([]byte, error) {
type MileageQueryModel struct {
Since time.Time `json:"since" query:"since" form:"since"`
MileageOption string `json:"mileageOption" query:"mileageOption" form:"mileageOption"`
}

View File

@@ -3,7 +3,8 @@ package models
import (
"time"
"github.com/akhilrex/hammond/db"
"hammond/db"
_ "github.com/go-playground/validator/v10"
)
@@ -17,6 +18,7 @@ type SubItemQuery struct {
type CreateVehicleRequest struct {
Nickname string `form:"nickname" json:"nickname" binding:"required"`
Registration string `form:"registration" json:"registration" binding:"required"`
VIN string `form:"vin" json:"vin"`
Make string `form:"make" json:"make" binding:"required"`
Model string `form:"model" json:"model" binding:"required"`
YearOfManufacture int `form:"yearOfManufacture" json:"yearOfManufacture"`

View File

@@ -0,0 +1,172 @@
package service
import (
"errors"
"time"
"hammond/db"
"hammond/models"
)
func CreateAlert(model models.CreateAlertModel, vehicleId, userId string) (*db.VehicleAlert, error) {
alert := db.VehicleAlert{
VehicleID: vehicleId,
UserID: userId,
Title: model.Title,
Comments: model.Comments,
StartDate: model.StartDate,
StartOdoReading: model.StartOdoReading,
DistanceUnit: *model.DistanceUnit,
AlertFrequency: *model.AlertFrequency,
OdoFrequency: model.OdoFrequency,
DayFrequency: model.DayFrequency,
AlertAllUsers: model.AlertAllUsers,
IsActive: model.IsActive,
AlertType: *model.AlertType,
}
tx := db.DB.Create(&alert)
if tx.Error != nil {
return nil, tx.Error
}
go CreateAlertInstance(alert.ID)
return &alert, nil
}
func CreateAlertInstance(alertId string) error {
alert, err := db.GeAlertById(alertId)
if err != nil {
return err
}
existingOccurence, err := db.GetAlertOccurenceByAlertId(alertId)
if err != nil {
return err
}
var lastOccurance db.AlertOccurance
useOccurance := false
if len(*existingOccurence) > 0 {
lastOccurance = (*existingOccurence)[0]
useOccurance = true
if alert.AlertFrequency == db.ONETIME {
return errors.New("Only single occurance is possible for this kind of alert")
}
}
users := []string{alert.UserID}
if alert.AlertAllUsers {
allUsers, err := db.GetVehicleUsers(alert.VehicleID)
if err != nil {
return err
}
users = make([]string, len(*allUsers))
for i, user := range *allUsers {
users[i] = user.UserID
}
}
for _, userId := range users {
model := db.AlertOccurance{
VehicleID: alert.VehicleID,
UserID: userId,
VehicleAlertID: alertId,
}
if alert.AlertType == db.DISTANCE || alert.AlertType == db.BOTH {
model.OdoReading = alert.StartOdoReading + alert.OdoFrequency
if useOccurance {
model.OdoReading = lastOccurance.OdoReading + alert.OdoFrequency
}
}
if alert.AlertType == db.TIME || alert.AlertType == db.BOTH {
date := alert.StartDate.Add(time.Duration(alert.DayFrequency) * 24 * time.Hour)
if useOccurance {
date = lastOccurance.Date.Add(time.Duration(alert.DayFrequency) * 24 * time.Hour)
}
model.Date = &date
}
tx := db.DB.Create(&model)
if tx.Error != nil {
return tx.Error
}
}
return nil
}
func ProcessAlertOccurance(occurance db.AlertOccurance, today time.Time) error {
if occurance.ProcessDate != nil {
return errors.New("Alert occurence already processed")
}
alert := occurance.VehicleAlert
if !alert.IsActive {
return errors.New("Alert is not active")
}
notification := db.Notification{
Title: alert.Title,
Content: alert.Comments,
UserID: occurance.UserID,
VehicleID: occurance.VehicleID,
Date: today,
ParentID: occurance.ID,
ParentType: "AlertOccurance",
}
var alertProcessType db.AlertType
if alert.AlertType == db.DISTANCE || alert.AlertType == db.BOTH {
odoReading, err := GetLatestOdoReadingForVehicle(occurance.VehicleID)
if err != nil {
return err
}
if odoReading >= occurance.OdoReading {
alertProcessType = db.DISTANCE
}
}
if alert.AlertType == db.TIME || alert.AlertType == db.BOTH {
if occurance.Date.Before(today) {
alertProcessType = db.TIME
}
}
db.DB.Create(&notification)
return db.MarkAlertOccuranceAsProcessed(occurance.ID, alertProcessType, today)
}
func FindAlertOccurancesToProcess(today time.Time) ([]db.AlertOccurance, error) {
occurances, err := db.GetUnprocessedAlertOccurances()
if err != nil {
return nil, err
}
if len(*occurances) == 0 {
return make([]db.AlertOccurance, 0), nil
}
var toReturn []db.AlertOccurance
for _, occurance := range *occurances {
alert := occurance.VehicleAlert
if !alert.IsActive {
continue
}
if alert.AlertType == db.DISTANCE || alert.AlertType == db.BOTH {
odoReading, err := GetLatestOdoReadingForVehicle(occurance.VehicleID)
if err != nil {
return nil, err
}
if odoReading >= occurance.OdoReading {
toReturn = append(toReturn, occurance)
continue
}
}
if alert.AlertType == db.TIME || alert.AlertType == db.BOTH {
if occurance.Date.Before(today) {
toReturn = append(toReturn, occurance)
continue
}
}
}
return toReturn, nil
}
func MarkAlertOccuranceAsCompleted() {
}

View File

@@ -0,0 +1,142 @@
package service
import (
"bytes"
"encoding/csv"
"fmt"
"strconv"
"strings"
"time"
"hammond/db"
)
func DrivvoParseExpenses(content []byte, user *db.User, vehicle *db.Vehicle) ([]db.Expense, []string) {
expenseReader := csv.NewReader(bytes.NewReader(content))
expenseReader.Comment = '#'
// Read headers (there is a trailing comma at the end, that's why we have to read the first line)
expenseReader.Read()
expenseReader.FieldsPerRecord = 6
expenseRecords, err := expenseReader.ReadAll()
var errors []string
if err != nil {
errors = append(errors, err.Error())
println(err.Error())
return nil, errors
}
var expenses []db.Expense
for index, record := range expenseRecords {
date, err := time.Parse("2006-01-02 15:04:05", record[1])
if err != nil {
errors = append(errors, "Found an invalid date/time at service/expense row "+strconv.Itoa(index+1))
}
totalCost, err := strconv.ParseFloat(record[2], 32)
if err != nil {
errors = append(errors, "Found and invalid total cost at service/expense row "+strconv.Itoa(index+1))
}
odometer, err := strconv.Atoi(record[0])
if err != nil {
errors = append(errors, "Found an invalid odometer reading at service/expense row "+strconv.Itoa(index+1))
}
notes := fmt.Sprintf("Location: %s\nNotes: %s\n", record[4], record[5])
expenses = append(expenses, db.Expense{
UserID: user.ID,
VehicleID: vehicle.ID,
Date: date,
OdoReading: odometer,
Amount: float32(totalCost),
ExpenseType: record[3],
Currency: user.Currency,
DistanceUnit: user.DistanceUnit,
Comments: notes,
Source: "Drivvo",
})
}
return expenses, errors
}
func DrivvoParseRefuelings(content []byte, user *db.User, vehicle *db.Vehicle, importLocation bool) ([]db.Fillup, []string) {
refuelingReader := csv.NewReader(bytes.NewReader(content))
refuelingReader.Comment = '#'
refuelingRecords, err := refuelingReader.ReadAll()
var errors []string
if err != nil {
errors = append(errors, err.Error())
println(err.Error())
return nil, errors
}
var fillups []db.Fillup
for index, record := range refuelingRecords {
// Skip column titles
if index == 0 {
continue
}
date, err := time.Parse("2006-01-02 15:04:05", record[1])
if err != nil {
errors = append(errors, "Found an invalid date/time at refuel row "+strconv.Itoa(index+1))
}
totalCost, err := strconv.ParseFloat(record[4], 32)
if err != nil {
errors = append(errors, "Found and invalid total cost at refuel row "+strconv.Itoa(index+1))
}
odometer, err := strconv.Atoi(record[0])
if err != nil {
errors = append(errors, "Found an invalid odometer reading at refuel row "+strconv.Itoa(index+1))
}
location := ""
if importLocation {
location = record[17]
}
pricePerUnit, err := strconv.ParseFloat(record[3], 32)
if err != nil {
unit := strings.ToLower(db.FuelUnitDetails[vehicle.FuelUnit].Key)
errors = append(errors, fmt.Sprintf("Found an invalid cost per %s at refuel row %d", unit, index+1))
}
quantity, err := strconv.ParseFloat(record[5], 32)
if err != nil {
errors = append(errors, "Found an invalid quantity at refuel row "+strconv.Itoa(index+1))
}
isTankFull := record[6] == "Yes"
// Unfortunatly, drivvo doesn't expose this info in their export
fal := false
notes := fmt.Sprintf("Reason: %s\nNotes: %s\nFuel: %s\n", record[18], record[19], record[2])
fillups = append(fillups, db.Fillup{
VehicleID: vehicle.ID,
UserID: user.ID,
Date: date,
HasMissedFillup: &fal,
IsTankFull: &isTankFull,
FuelQuantity: float32(quantity),
PerUnitPrice: float32(pricePerUnit),
FillingStation: location,
OdoReading: odometer,
TotalAmount: float32(totalCost),
FuelUnit: vehicle.FuelUnit,
Currency: user.Currency,
DistanceUnit: user.DistanceUnit,
Comments: notes,
Source: "Drivvo",
})
}
return fillups, errors
}

View File

@@ -3,7 +3,6 @@ package service
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"net/http"
@@ -14,9 +13,10 @@ import (
"strconv"
"time"
"github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/internal/sanitize"
"github.com/akhilrex/hammond/models"
"hammond/db"
"hammond/internal/sanitize"
"hammond/models"
uuid "github.com/satori/go.uuid"
)
@@ -126,14 +126,14 @@ func CreateBackup() (string, error) {
tarballFilePath := path.Join(folder, backupFileName)
file, err := os.Create(tarballFilePath)
if err != nil {
return "", errors.New(fmt.Sprintf("Could not create tarball file '%s', got error '%s'", tarballFilePath, err.Error()))
return "", fmt.Errorf("could not create tarball file '%s', got error '%s'", tarballFilePath, err.Error())
}
defer file.Close()
dbPath := path.Join(configPath, "hammond.db")
_, err = os.Stat(dbPath)
if err != nil {
return "", errors.New(fmt.Sprintf("Could not find db file '%s', got error '%s'", dbPath, err.Error()))
return "", fmt.Errorf("could not find db file '%s', got error '%s'", dbPath, err.Error())
}
gzipWriter := gzip.NewWriter(file)
defer gzipWriter.Close()
@@ -151,13 +151,13 @@ func CreateBackup() (string, error) {
func addFileToTarWriter(filePath string, tarWriter *tar.Writer) error {
file, err := os.Open(filePath)
if err != nil {
return errors.New(fmt.Sprintf("Could not open file '%s', got error '%s'", filePath, err.Error()))
return fmt.Errorf("could not open file '%s', got error '%s'", filePath, err.Error())
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return errors.New(fmt.Sprintf("Could not get stat for file '%s', got error '%s'", filePath, err.Error()))
return fmt.Errorf("could not get stat for file '%s', got error '%s'", filePath, err.Error())
}
header := &tar.Header{
@@ -169,12 +169,12 @@ func addFileToTarWriter(filePath string, tarWriter *tar.Writer) error {
err = tarWriter.WriteHeader(header)
if err != nil {
return errors.New(fmt.Sprintf("Could not write header for file '%s', got error '%s'", filePath, err.Error()))
return fmt.Errorf("could not write header for file '%s', got error '%s'", filePath, err.Error())
}
_, err = io.Copy(tarWriter, file)
if err != nil {
return errors.New(fmt.Sprintf("Could not copy the file '%s' data to the tarball, got error '%s'", filePath, err.Error()))
return fmt.Errorf("could not copy the file '%s' data to the tarball, got error '%s'", filePath, err.Error())
}
return nil

View File

@@ -0,0 +1,141 @@
package service
import (
"bytes"
"encoding/csv"
"fmt"
"strconv"
"time"
"hammond/db"
"github.com/leekchan/accounting"
)
func FuellyParseAll(content []byte, userId string) ([]db.Fillup, []db.Expense, []string) {
stream := bytes.NewReader(content)
reader := csv.NewReader(stream)
records, err := reader.ReadAll()
var errors []string
user, err := GetUserById(userId)
if err != nil {
errors = append(errors, err.Error())
return nil, nil, errors
}
vehicles, err := GetUserVehicles(userId)
if err != nil {
errors = append(errors, err.Error())
return nil, nil, errors
}
if err != nil {
errors = append(errors, err.Error())
return nil, nil, errors
}
var vehicleMap map[string]db.Vehicle = make(map[string]db.Vehicle)
for _, vehicle := range *vehicles {
vehicleMap[vehicle.Nickname] = vehicle
}
var fillups []db.Fillup
var expenses []db.Expense
layout := "2006-01-02 15:04"
altLayout := "2006-01-02 3:04 PM"
for index, record := range records {
if index == 0 {
continue
}
var vehicle db.Vehicle
var ok bool
if vehicle, ok = vehicleMap[record[4]]; !ok {
errors = append(errors, "Found an unmapped vehicle entry at row "+strconv.Itoa(index+1))
}
dateStr := record[2] + " " + record[3]
date, err := time.Parse(layout, dateStr)
if err != nil {
date, err = time.Parse(altLayout, dateStr)
}
if err != nil {
errors = append(errors, "Found an invalid date/time at row "+strconv.Itoa(index+1))
}
totalCostStr := accounting.UnformatNumber(record[9], 3, user.Currency)
totalCost64, err := strconv.ParseFloat(totalCostStr, 32)
if err != nil {
errors = append(errors, "Found an invalid total cost at row "+strconv.Itoa(index+1))
}
totalCost := float32(totalCost64)
odoStr := accounting.UnformatNumber(record[5], 0, user.Currency)
odoreading, err := strconv.Atoi(odoStr)
if err != nil {
errors = append(errors, "Found an invalid odo reading at row "+strconv.Itoa(index+1))
}
location := record[12]
//Create Fillup
if record[0] == "Gas" {
rateStr := accounting.UnformatNumber(record[7], 3, user.Currency)
ratet64, err := strconv.ParseFloat(rateStr, 32)
if err != nil {
errors = append(errors, "Found an invalid cost per gallon at row "+strconv.Itoa(index+1))
}
rate := float32(ratet64)
quantity64, err := strconv.ParseFloat(record[8], 32)
if err != nil {
errors = append(errors, "Found an invalid quantity at row "+strconv.Itoa(index+1))
}
quantity := float32(quantity64)
notes := fmt.Sprintf("Octane:%s\nGas Brand:%s\nLocation%s\nTags:%s\nPayment Type:%s\nTire Pressure:%s\nNotes:%s\nMPG:%s",
record[10], record[11], record[12], record[13], record[14], record[15], record[16], record[1],
)
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,
HasMissedFillup: &fal,
UserID: userId,
Date: date,
Currency: user.Currency,
DistanceUnit: user.DistanceUnit,
Source: "Fuelly",
})
}
if record[0] == "Service" {
notes := fmt.Sprintf("Tags:%s\nPayment Type:%s\nNotes:%s",
record[13], record[14], record[16],
)
expenses = append(expenses, db.Expense{
VehicleID: vehicle.ID,
Amount: totalCost,
OdoReading: odoreading,
Comments: notes,
ExpenseType: record[17],
UserID: userId,
Currency: user.Currency,
Date: date,
DistanceUnit: user.DistanceUnit,
Source: "Fuelly",
})
}
}
return fillups, expenses, errors
}

View File

@@ -0,0 +1,47 @@
package service
import (
"hammond/db"
"hammond/models"
"time"
)
func GenericParseRefuelings(content []models.ImportFillup, user *db.User, vehicle *db.Vehicle, timezone string) ([]db.Fillup, []string) {
var errors []string
var fillups []db.Fillup
dateLayout := "2006-01-02T15:04:05.000Z"
loc, _ := time.LoadLocation(timezone)
for _, record := range content {
date, err := time.ParseInLocation(dateLayout, record.Date, loc)
if err != nil {
date = time.Date(2000, time.December, 0, 0, 0, 0, 0, loc)
}
var missedFillup bool
if record.HasMissedFillup == nil {
missedFillup = false
} else {
missedFillup = *record.HasMissedFillup
}
fillups = append(fillups, db.Fillup{
VehicleID: vehicle.ID,
UserID: user.ID,
Date: date,
IsTankFull: record.IsTankFull,
HasMissedFillup: &missedFillup,
FuelQuantity: float32(record.FuelQuantity),
PerUnitPrice: float32(record.PerUnitPrice),
FillingStation: record.FillingStation,
OdoReading: record.OdoReading,
TotalAmount: float32(record.TotalAmount),
FuelUnit: vehicle.FuelUnit,
Currency: user.Currency,
DistanceUnit: user.DistanceUnit,
Comments: record.Comments,
Source: "Generic Import",
})
}
return fillups, errors
}

View File

@@ -2,144 +2,13 @@ package service
import (
"bytes"
"encoding/csv"
"fmt"
"strconv"
"time"
"github.com/akhilrex/hammond/db"
"github.com/leekchan/accounting"
"hammond/db"
"hammond/models"
)
func FuellyImport(content []byte, userId string) []string {
stream := bytes.NewReader(content)
reader := csv.NewReader(stream)
records, err := reader.ReadAll()
func WriteToDB(fillups []db.Fillup, expenses []db.Expense) []string {
var errors []string
if err != nil {
errors = append(errors, err.Error())
return errors
}
vehicles, err := GetUserVehicles(userId)
if err != nil {
errors = append(errors, err.Error())
return errors
}
user, err := GetUserById(userId)
if err != nil {
errors = append(errors, err.Error())
return errors
}
var vehicleMap map[string]db.Vehicle = make(map[string]db.Vehicle)
for _, vehicle := range *vehicles {
vehicleMap[vehicle.Nickname] = vehicle
}
var fillups []db.Fillup
var expenses []db.Expense
layout := "2006-01-02 15:04"
altLayout := "2006-01-02 3:04 PM"
for index, record := range records {
if index == 0 {
continue
}
var vehicle db.Vehicle
var ok bool
if vehicle, ok = vehicleMap[record[4]]; !ok {
errors = append(errors, "Found an unmapped vehicle entry at row "+strconv.Itoa(index+1))
}
dateStr := record[2] + " " + record[3]
date, err := time.Parse(layout, dateStr)
if err != nil {
date, err = time.Parse(altLayout, dateStr)
}
if err != nil {
errors = append(errors, "Found an invalid date/time at row "+strconv.Itoa(index+1))
}
totalCostStr := accounting.UnformatNumber(record[9], 3, user.Currency)
totalCost64, err := strconv.ParseFloat(totalCostStr, 32)
if err != nil {
errors = append(errors, "Found an invalid total cost at row "+strconv.Itoa(index+1))
}
totalCost := float32(totalCost64)
odoStr := accounting.UnformatNumber(record[5], 0, user.Currency)
odoreading, err := strconv.Atoi(odoStr)
if err != nil {
errors = append(errors, "Found an invalid odo reading at row "+strconv.Itoa(index+1))
}
location := record[12]
//Create Fillup
if record[0] == "Gas" {
rateStr := accounting.UnformatNumber(record[7], 3, user.Currency)
ratet64, err := strconv.ParseFloat(rateStr, 32)
if err != nil {
errors = append(errors, "Found an invalid cost per gallon at row "+strconv.Itoa(index+1))
}
rate := float32(ratet64)
quantity64, err := strconv.ParseFloat(record[8], 32)
if err != nil {
errors = append(errors, "Found an invalid quantity at row "+strconv.Itoa(index+1))
}
quantity := float32(quantity64)
notes := fmt.Sprintf("Octane:%s\nGas Brand:%s\nLocation%s\nTags:%s\nPayment Type:%s\nTire Pressure:%s\nNotes:%s\nMPG:%s",
record[10], record[11], record[12], record[13], record[14], record[15], record[16], record[1],
)
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,
HasMissedFillup: &fal,
UserID: userId,
Date: date,
Currency: user.Currency,
DistanceUnit: user.DistanceUnit,
Source: "Fuelly",
})
}
if record[0] == "Service" {
notes := fmt.Sprintf("Tags:%s\nPayment Type:%s\nNotes:%s",
record[13], record[14], record[16],
)
expenses = append(expenses, db.Expense{
VehicleID: vehicle.ID,
Amount: totalCost,
OdoReading: odoreading,
Comments: notes,
ExpenseType: record[17],
UserID: userId,
Currency: user.Currency,
Date: date,
DistanceUnit: user.DistanceUnit,
Source: "Fuelly",
})
}
}
if len(errors) != 0 {
return errors
}
tx := db.DB.Begin()
defer func() {
if r := recover(); r != nil {
@@ -150,19 +19,114 @@ func FuellyImport(content []byte, userId string) []string {
errors = append(errors, err.Error())
return errors
}
if err := tx.Create(&fillups).Error; err != nil {
tx.Rollback()
errors = append(errors, err.Error())
return errors
if fillups != nil {
if err := tx.Create(&fillups).Error; err != nil {
tx.Rollback()
errors = append(errors, err.Error())
return errors
}
}
if err := tx.Create(&expenses).Error; err != nil {
tx.Rollback()
errors = append(errors, err.Error())
return errors
if expenses != nil {
if err := tx.Create(&expenses).Error; err != nil {
tx.Rollback()
errors = append(errors, err.Error())
return errors
}
}
err = tx.Commit().Error
err := tx.Commit().Error
if err != nil {
errors = append(errors, err.Error())
}
return errors
}
func DrivvoImport(content []byte, userId string, vehicleId string, importLocation bool) []string {
var errors []string
user, err := GetUserById(userId)
if err != nil {
errors = append(errors, err.Error())
return errors
}
vehicle, err := GetVehicleById(vehicleId)
if err != nil {
errors = append(errors, err.Error())
return errors
}
endParseIndex := bytes.Index(content, []byte("#Income"))
if endParseIndex == -1 {
endParseIndex = bytes.Index(content, []byte("#Route"))
if endParseIndex == -1 {
endParseIndex = len(content)
}
}
serviceEndIndex := bytes.Index(content, []byte("#Expense"))
if serviceEndIndex == -1 {
serviceEndIndex = endParseIndex
}
refuelEndIndex := bytes.Index(content, []byte("#Service"))
if refuelEndIndex == -1 {
refuelEndIndex = serviceEndIndex
}
var fillups []db.Fillup
fillups, errors = DrivvoParseRefuelings(content[:refuelEndIndex], user, vehicle, importLocation)
var allExpenses []db.Expense
services, parseErrors := DrivvoParseExpenses(content[refuelEndIndex:serviceEndIndex], user, vehicle)
if parseErrors != nil {
errors = append(errors, parseErrors...)
}
allExpenses = append(allExpenses, services...)
expenses, parseErrors := DrivvoParseExpenses(content[serviceEndIndex:endParseIndex], user, vehicle)
if parseErrors != nil {
errors = append(errors, parseErrors...)
}
allExpenses = append(allExpenses, expenses...)
if len(errors) != 0 {
return errors
}
return WriteToDB(fillups, allExpenses)
}
func FuellyImport(content []byte, userId string) []string {
fillups, expenses, errors := FuellyParseAll(content, userId)
if len(errors) != 0 {
return errors
}
return WriteToDB(fillups, expenses)
}
func GenericImport(content models.ImportData, userId string) []string {
var errors []string
user, err := GetUserById(userId)
if err != nil {
errors = append(errors, err.Error())
return errors
}
vehicle, err := GetVehicleById(content.VehicleId)
if err != nil {
errors = append(errors, err.Error())
return errors
}
var fillups []db.Fillup
fillups, errors = GenericParseRefuelings(content.Data, user, vehicle, content.TimeZone)
if len(errors) != 0 {
return errors
}
return WriteToDB(fillups, nil)
}

View File

@@ -1,7 +1,7 @@
package service
import (
"github.com/akhilrex/hammond/db"
"hammond/db"
)
func CanInitializeSystem() (bool, error) {

View File

@@ -1,13 +1,15 @@
package service
import (
"sort"
"time"
"github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/models"
"hammond/common"
"hammond/db"
"hammond/models"
)
func GetMileageByVehicleId(vehicleId string, since time.Time) (mileage []models.MileageModel, err error) {
func GetMileageByVehicleId(vehicleId string, since time.Time, mileageOption string) (mileage []models.MileageModel, err error) {
data, err := db.GetFillupsByVehicleIdSince(vehicleId, since)
if err != nil {
return nil, err
@@ -15,6 +17,9 @@ func GetMileageByVehicleId(vehicleId string, since time.Time) (mileage []models.
fillups := make([]db.Fillup, len(*data))
copy(fillups, *data)
sort.Slice(fillups, func(i, j int) bool {
return fillups[i].OdoReading > fillups[j].OdoReading
})
var mileages []models.MileageModel
@@ -32,14 +37,48 @@ func GetMileageByVehicleId(vehicleId string, since time.Time) (mileage []models.
PerUnitPrice: currentFillup.PerUnitPrice,
OdoReading: currentFillup.OdoReading,
Currency: currentFillup.Currency,
DistanceUnit: currentFillup.DistanceUnit,
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
currentOdoReading := float32(currentFillup.OdoReading);
lastFillupOdoReading := float32(lastFillup.OdoReading);
currentFuelQuantity := float32(currentFillup.FuelQuantity);
// If miles per gallon option and distanceUnit is km, convert from km to miles
// then check if fuel unit is litres. If it is, convert to gallons
if (mileageOption == "mpg" && mileage.DistanceUnit == db.KILOMETERS) {
currentOdoReading = common.KmToMiles(currentOdoReading);
lastFillupOdoReading = common.KmToMiles(lastFillupOdoReading);
if (mileage.FuelUnit == db.LITRE) {
currentFuelQuantity = common.LitreToGallon(currentFuelQuantity);
}
}
// If km_litre option or litre per 100km and distanceUnit is miles, convert from miles to km
// then check if fuel unit is not litres. If it isn't, convert to litres
if ((mileageOption == "km_litre" || mileageOption == "litre_100km") && mileage.DistanceUnit == db.MILES) {
currentOdoReading = common.MilesToKm(currentOdoReading);
lastFillupOdoReading = common.MilesToKm(lastFillupOdoReading);
if (mileage.FuelUnit == db.US_GALLON) {
currentFuelQuantity = common.GallonToLitre(currentFuelQuantity);
}
}
distance := float32(currentOdoReading - lastFillupOdoReading);
if (mileageOption == "litre_100km") {
mileage.Mileage = currentFuelQuantity / distance * 100;
} else {
mileage.Mileage = distance / currentFuelQuantity;
}
mileage.CostPerMile = distance / currentFillup.TotalAmount;
}

View File

@@ -1,14 +1,16 @@
package service
import (
"github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/models"
"strings"
"hammond/db"
"hammond/models"
)
func CreateUser(userModel *models.RegisterRequest, role db.Role) error {
setting := db.GetOrCreateSetting()
toCreate := db.User{
Email: userModel.Email,
Email: strings.ToLower(userModel.Email),
Name: userModel.Name,
Role: role,
Currency: setting.Currency,

View File

@@ -3,8 +3,10 @@ package service
import (
"fmt"
"github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/models"
"hammond/db"
"hammond/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -13,6 +15,7 @@ func CreateVehicle(model models.CreateVehicleRequest, userId string) (*db.Vehicl
Nickname: model.Nickname,
Registration: model.Registration,
Model: model.Model,
VIN: model.VIN,
Make: model.Make,
YearOfManufacture: model.YearOfManufacture,
EngineSize: model.EngineSize,
@@ -99,6 +102,7 @@ func UpdateVehicle(vehicleID string, model models.UpdateVehicleRequest) error {
//return db.DB.Model(&toUpdate).Updates(db.Vehicle{
toUpdate.Nickname = model.Nickname
toUpdate.Registration = model.Registration
toUpdate.VIN = model.VIN
toUpdate.Model = model.Model
toUpdate.Make = model.Make
toUpdate.YearOfManufacture = model.YearOfManufacture
@@ -243,6 +247,24 @@ func GetDistinctFuelSubtypesForVehicle(vehicleId string) ([]string, error) {
return names, tx.Error
}
func GetLatestOdoReadingForVehicle(vehicleId string) (int, error) {
odoReading := 0
latestFillup, err := db.GetLatestExpenseByVehicleId(vehicleId)
if err != nil && err != gorm.ErrRecordNotFound {
return 0, err
}
odoReading = latestFillup.OdoReading
latestExpense, err := db.GetLatestExpenseByVehicleId(vehicleId)
if err != nil && err != gorm.ErrRecordNotFound {
return 0, err
}
if latestExpense.OdoReading > odoReading {
odoReading = latestExpense.OdoReading
}
return odoReading, nil
}
func GetUserStats(userId string, model models.UserStatsQueryModel) ([]models.VehicleStatsModel, error) {
vehicles, err := GetUserVehicles(userId)

View File

@@ -20,6 +20,7 @@ module.exports = {
'no-console': process.env.PRE_COMMIT
? ['error', { allow: ['warn', 'error'] }]
: 'off',
'vue/multi-word-component-names': 0,
'import/no-relative-parent-imports': 'error',
'import/order': 'error',
'vue/array-bracket-spacing': 'error',

5
ui/.gitignore vendored
View File

@@ -29,3 +29,8 @@ yarn-error.log*
*.njsproj
*.sln
*.sw*
#Vs code files
.vscode
!.vscode/launch.json

View File

@@ -1,30 +0,0 @@
{
"BaseButton": {
"scope": "vue-html",
"prefix": "BaseButton",
"body": ["<BaseButton>", "\t${3}", "</BaseButton>"],
"description": "<BaseButton>"
},
"BaseIcon": {
"scope": "vue-html",
"prefix": "BaseIcon",
"body": ["<BaseIcon name=\"${1}\">", "\t${2}", "</BaseIcon>"],
"description": "<BaseIcon>"
},
"BaseInputText": {
"scope": "vue-html",
"prefix": "BaseInputText",
"body": ["<BaseInputText ${1}/>"],
"description": "<BaseInputText>"
},
"BaseLink": {
"scope": "vue-html",
"prefix": "BaseLink",
"body": [
"<BaseLink ${1|name,:to,href|}=\"${2:route}\">",
"\t${3}",
"</BaseLink>"
],
"description": "<BaseLink>"
}
}

View File

@@ -1,26 +0,0 @@
{
"script": {
"scope": "vue",
"prefix": "script",
"body": ["<script>", "export default {", "\t${0}", "}", "</script>"],
"description": "<script>"
},
"template": {
"scope": "vue",
"prefix": "template",
"body": ["<template>", "\t${0}", "</template>"],
"description": "<template>"
},
"style": {
"scope": "vue",
"prefix": "style",
"body": [
"<style lang=\"scss\" module>",
"@import '@design';",
"",
"${0}",
"</style>"
],
"description": "<style lang=\"scss\" module>"
}
}

View File

@@ -1,37 +0,0 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
// Syntax highlighting and more for .vue files
// https://github.com/vuejs/vetur
"octref.vetur",
// Peek and go-to-definition for .vue files
// https://github.com/fuzinato/vscode-vue-peek
"dariofuzinato.vue-peek",
// Lint-on-save with ESLint
// https://github.com/microsoft/vscode-eslint
"dbaeumer.vscode-eslint",
// Lint-on-save with Stylelint
// https://github.com/stylelint/vscode-stylelint
"stylelint.vscode-stylelint",
// Lint-on-save markdown in README files
// https://github.com/DavidAnson/vscode-markdownlint
"DavidAnson.vscode-markdownlint",
// Format-on-save with Prettier
// https://github.com/prettier/prettier-vscode
"esbenp.prettier-vscode",
// SCSS intellisense
// https://github.com/mrmlnc/vscode-scss
"mrmlnc.vscode-scss",
// Test `.unit.js` files on save with Jest
// https://github.com/jest-community/vscode-jest
"Orta.vscode-jest"
]
}

View File

@@ -1,93 +0,0 @@
{
// ===
// Spacing
// ===
"editor.insertSpaces": true,
"editor.tabSize": 2,
"editor.trimAutoWhitespace": true,
"files.trimTrailingWhitespace": true,
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
// ===
// Files
// ===
"files.exclude": {
"**/*.log": true,
"**/*.log*": true,
"**/dist": true,
"**/coverage": true
},
"files.associations": {
".markdownlintrc": "jsonc"
},
// ===
// Event Triggers
// ===
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": true,
"source.fixAll.markdownlint": true
},
"eslint.validate": [
"javascript",
"javascriptreact",
"vue",
"vue-html",
"html"
],
"vetur.format.enable": false,
"vetur.completion.scaffoldSnippetSources": {
"user": "🗒️",
"workspace": "💼",
"vetur": ""
},
"prettier.disableLanguages": [],
// ===
// HTML
// ===
"html.format.enable": false,
"vetur.validation.template": false,
"emmet.triggerExpansionOnTab": true,
"emmet.includeLanguages": {
"vue-html": "html"
},
"vetur.completion.tagCasing": "initial",
// ===
// JS(ON)
// ===
"jest.autoEnable": false,
"jest.enableCodeLens": false,
"javascript.format.enable": false,
"json.format.enable": false,
"vetur.validation.script": false,
// ===
// CSS
// ===
"stylelint.enable": true,
"css.validate": false,
"scss.validate": false,
"vetur.validation.style": false,
// ===
// MARKDOWN
// ===
"[markdown]": {
"editor.wordWrap": "wordWrapColumn",
"editor.wordWrapColumn": 80
}
}

View File

@@ -6,10 +6,9 @@ WORKDIR /app
# Copy dependency-related files
COPY package.json ./
COPY yarn.lock ./
# Install project dependencies
RUN yarn install
RUN npm install
# Expose ports 8080, which the dev server will be bound to
EXPOSE 8080

47507
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,89 +6,82 @@
"dev": "vue-cli-service serve",
"dev:e2e": "cross-env VUE_APP_TEST=e2e vue-cli-service test:e2e --mode=development",
"build": "vue-cli-service build --modern",
"build:ci": "yarn build --report",
"build:ci": "npm build --report",
"lint:eslint": "eslint --fix",
"lint:stylelint": "stylelint --fix",
"lint:markdownlint": "markdownlint",
"lint:prettier": "prettier --write --loglevel warn",
"lint:all:eslint": "yarn lint:eslint --ext .js,.vue .",
"lint:all:stylelint": "yarn lint:stylelint \"src/**/*.{vue,scss}\"",
"lint:all:markdownlint": "yarn lint:markdownlint \"docs/*.md\" \"*.md\"",
"lint:all:prettier": "yarn lint:prettier \"**/*.{js,json,css,scss,vue,html,md}\"",
"lint": "run-s lint:all:*",
"test:unit": "cross-env VUE_APP_TEST=unit vue-cli-service test:unit",
"test:unit:file": "yarn test:unit --bail --findRelatedTests",
"test:unit:watch": "yarn test:unit --watch --notify --notifyMode change",
"test:unit:ci": "yarn test:unit --coverage --ci",
"test:e2e": "cross-env VUE_APP_TEST=e2e vue-cli-service test:e2e --headless",
"test": "run-s test:unit test:e2e",
"test:ci": "run-s test:unit:ci test:e2e",
"new": "cross-env HYGEN_TMPLS=generators hygen new",
"docs": "vuepress dev",
"docker": "docker-compose exec dev yarn"
"docs": "vuepress dev"
},
"gitHooks": {
"pre-commit": "cross-env PRE_COMMIT=true lint-staged"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.27",
"@fortawesome/free-solid-svg-icons": "^5.12.1",
"@fortawesome/vue-fontawesome": "0.1.9",
"axios": "0.19.2",
"buefy": "^0.9.7",
"@fortawesome/fontawesome-svg-core": "^6.3.0",
"@fortawesome/free-solid-svg-icons": "^6.3.0",
"@fortawesome/vue-fontawesome": "^2.0.10",
"axios": "^1.3.2",
"buefy": "^0.9.22",
"chart.js": "^2.9.4",
"core-js": "3.6.4",
"currency-formatter": "^1.5.7",
"date-fns": "2.10.0",
"lodash": "4.17.15",
"normalize.css": "8.0.1",
"nprogress": "0.2.0",
"vue": "2.6.11",
"core-js": "^3.27.2",
"currency-formatter": "^1.5.9",
"date-fns": "^2.29.3",
"lodash": "^4.17.21",
"node-gyp": "^9.3.1",
"normalize.css": "^8.0.1",
"nprogress": "^0.2.0",
"papaparse": "^5.4.1",
"vue": "^2.6.11",
"vue-chartjs": "^3.5.1",
"vue-meta": "2.3.3",
"vue-router": "3.1.6",
"vuex": "3.1.2"
"vue-i18n": "^8.28.2",
"vue-meta": "^2.4.0",
"vue-router": "^3.6.5",
"vuex": "^3.6.2"
},
"devDependencies": {
"@vue/cli-plugin-babel": "4.2.x",
"@vue/cli-plugin-eslint": "4.2.x",
"@vue/cli-plugin-unit-jest": "4.2.x",
"@vue/cli-service": "4.2.x",
"@vue/cli-plugin-babel": "^4.5.19",
"@vue/cli-plugin-eslint": "^4.5.19",
"@vue/cli-plugin-unit-jest": "^4.5.19",
"@vue/cli-service": "^4.5.19",
"@vue/eslint-config-prettier": "6.0.x",
"@vue/eslint-config-standard": "5.1.x",
"@vue/test-utils": "1.0.0-beta.31",
"babel-core": "7.0.0-bridge.0",
"@vue/eslint-config-standard": "^5.1.1",
"@vue/test-utils": "^1.3.4",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "10.1.x",
"cross-env": "7.0.x",
"eslint": "6.8.x",
"eslint-plugin-import": "2.20.x",
"eslint-plugin-node": "11.0.x",
"eslint-plugin-promise": "4.2.x",
"eslint-plugin-standard": "4.0.x",
"eslint-plugin-vue": "6.2.x",
"express": "4.17.x",
"hygen": "4.0.x",
"imagemin-lint-staged": "0.4.x",
"lint-staged": "10.0.x",
"markdownlint-cli": "0.22.x",
"npm-run-all": "4.1.x",
"sass": "1.26.x",
"sass-loader": "8.0.x",
"stylelint": "13.2.x",
"stylelint-config-css-modules": "2.2.x",
"stylelint-config-prettier": "8.0.x",
"stylelint-config-recess-order": "2.0.x",
"stylelint-config-standard": "20.0.x",
"stylelint-scss": "3.14.x",
"vue-template-compiler": "2.6.11",
"vuepress": "1.3.x"
"cross-env": "^7.0.1",
"eslint": "^6.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.3.1",
"eslint-plugin-standard": "^5.0.0",
"eslint-plugin-vue": "^9.9.0",
"express": "^4.18.2",
"hygen": "^6.2.11",
"imagemin-lint-staged": "^0.5.1",
"lint-staged": "^13.1.1",
"markdownlint-cli": "^0.33.0",
"npm-run-all": "^4.1.1",
"sass": "^1.58.0",
"sass-loader": "^8.0.2",
"stylelint": "^14.16.1",
"stylelint-config-css-modules": "^4.1.0",
"stylelint-config-prettier": "^9.0.4",
"stylelint-config-standard": "^29.0.0",
"stylelint-scss": "^4.3.0",
"vue-template-compiler": "^2.6.11",
"vuepress": "^1.9.8"
},
"resolutions": {
"@vue/cli-plugin-unit-jest/jest": "25.1.x",
"@vue/cli-plugin-unit-jest/babel-jest": "25.1.x"
},
"engines": {
"node": ">=10.13.3",
"yarn": ">=1.0.0"
"node": ">=16.0.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 B

After

Width:  |  Height:  |  Size: 895 B

View File

@@ -5,7 +5,12 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="shortcut icon" href="<%= webpackConfig.output.publicPath %>hammond.png" />
<link rel="apple-touch-icon" href="<%= webpackConfig.output.publicPath %>touch-icon.png" />
<title><%= webpackConfig.name %></title>
<!-- Temporary until fontawesome 6 is supported in buefy (see issue: https://github.com/FortAwesome/Font-Awesome/issues/18663) -->
<style>
.icon svg { width: 1em; height: 1em; max-width: 80%; max-height: 80%; }
</style>
</head>
<body>
<!-- This is where our app will be mounted. -->

BIN
ui/public/touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -20,7 +20,7 @@ export default {
}
} else {
if (this.file == null) {
return 'Upload Photo'
return this.$t('uploadphoto')
} else {
return ''
}
@@ -39,7 +39,7 @@ export default {
.post(`/api/quickEntries`, formData)
.then((data) => {
this.$buefy.toast.open({
message: 'Quick Entry Created Successfully',
message: this.$t('quickentrycreatedsuccessfully'),
type: 'is-success',
duration: 3000,
})
@@ -68,9 +68,9 @@ export default {
<div class="section box">
<div class="columns">
<div class="column is-two-thirds">
<p class="title">Quick Entry</p>
<p class="title">{{ $tc('quickentry',1) }}</p>
<p class="subtitle"
>Take a pic of the invoice or the fuel pump display to make an entry later.</p
>{{ $t('quickentrydesc') }}</p
></div
>
<div class="column is-one-third is-flex is-align-content-center">
@@ -95,14 +95,13 @@ export default {
</div>
<div class="column">
<b-button
tag="input"
tag="button"
native-type="submit"
:disabled="tryingToCreate"
type="is-primary"
value="Upload File"
class="control"
>
Upload File
{{ $t('uploadfile') }}
</b-button>
</div></div
>

View File

@@ -3,9 +3,20 @@ import { Line } from 'vue-chartjs'
import axios from 'axios'
import { mapState } from 'vuex'
import { string } from 'yargs'
export default {
extends: Line,
props: { vehicle: { type: Object, required: true }, since: { type: Date, default: '' }, user: { type: Object, required: true } },
props: {
vehicle: { type: Object, required: true },
since: { type: Date, default: '' },
user: { type: Object, required: true },
mileageOption: { type: string, default: 'litre_100km' },
},
data: function() {
return {
chartData: [],
}
},
computed: {
...mapState('utils', ['isMobile']),
},
@@ -17,20 +28,28 @@ export default {
this.fetchMileage()
},
},
data: function() {
return {
chartData: [],
}
},
mounted() {
this.fetchMileage()
},
methods: {
showChart() {
let mileageLabel = ''
switch (this.mileageOption) {
case 'litre_100km':
mileageLabel = 'L/100km'
break
case 'km_litre':
mileageLabel = 'km/L'
break
case 'mpg':
mileageLabel = 'mpg'
break
}
var labels = this.chartData.map((x) => x.date.substr(0, 10))
var dataset = {
steppedLine: true,
label: `Mileage (${this.user.distanceUnitDetail.short}/${this.vehicle.fuelUnitDetail.short})`,
label: `Mileage (${mileageLabel})`,
fill: true,
data: this.chartData.map((x) => x.mileage),
}
@@ -41,6 +60,7 @@ export default {
.get(`/api/vehicles/${this.vehicle.id}/mileage`, {
params: {
since: this.since,
mileageOption: this.mileageOption,
},
})
.then((response) => {

View File

@@ -10,42 +10,42 @@ export default {
persistentNavRoutes: [
{
name: 'home',
title: 'Home',
title: this.$t('menu.home'),
},
],
loggedInNavRoutes: [
{
name: 'quickEntries',
title: () => 'Quick Entries',
title: () => this.$t('menu.quickentries'),
badge: () => this.unprocessedQuickEntries.length,
},
{
name: 'import',
title: () => 'Import',
title: () => this.$t('menu.import'),
},
{
name: 'settings',
title: 'Settings',
title: this.$t('menu.settings'),
},
{
name: 'logout',
title: 'Log out',
title: this.$t('menu.logout'),
},
],
loggedOutNavRoutes: [
{
name: 'login',
title: 'Log in',
title: this.$t('menu.login'),
},
],
adminNavRoutes: [
{
name: 'site-settings',
title: 'Site Settings',
title: this.$t('menu.sitesettings'),
},
{
name: 'users',
title: 'Users',
title: this.$t('menu.users'),
},
],
}
@@ -72,7 +72,7 @@ export default {
<NavBarRoutes :routes="persistentNavRoutes" />
<NavBarRoutes v-if="loggedIn" :routes="loggedInNavRoutes" />
<NavBarRoutes v-else :routes="loggedOutNavRoutes" />
<b-navbar-dropdown v-if="loggedIn && isAdmin" label="Admin">
<b-navbar-dropdown v-if="loggedIn && isAdmin" :label="$t('menu.admin')">
<NavBarRoutes :routes="adminNavRoutes" />
</b-navbar-dropdown>
</template>

View File

@@ -50,12 +50,12 @@ export default {
<b-select
v-if="unprocessedQuickEntries.length"
v-model="quickEntry"
placeholder="Refer quick entry"
:placeholder="$t('referquickentry')"
expanded
@input="showQuickEntry($event)"
>
<option v-for="option in unprocessedQuickEntries" :key="option.id" :value="option">
Taken: {{ parseAndFormatDateTime(option.createdAt) }}
{{ $t('created') }}: {{ parseAndFormatDateTime(option.createdAt) }}
</option>
</b-select>
<p class="control">

View File

@@ -55,7 +55,7 @@ export default {
return
}
this.$buefy.dialog.confirm({
title: 'Transfer Vehicle',
title: this.$t('transfervehicle'),
message: 'Are you sure you want to do this? You will lose ownership and all editing rights if you confirm.',
cancelText: 'Cancel',
confirmText: 'Go Ahead',
@@ -90,9 +90,9 @@ export default {
<template>
<div class="box" style="max-width:600px">
<h1 class="subtitle">Share {{ vehicle.nickname }}</h1>
<h1 class="subtitle">{{ $t('share') }} {{ vehicle.nickname }}</h1>
<section>
<div class="columns is-mobile" v-for="model in models" :key="model.id">
<div v-for="model in models" :key="model.id" class="columns is-mobile">
<div class="column is-one-third">
<b-field>
<b-switch v-model="model.isShared" :disabled="model.isOwner" @input="changeShareStatus(model)">
@@ -101,7 +101,7 @@ export default {
</b-field> </div
><div class="column is-three-quarters">
<b-field>
<b-button v-if="model.isShared && !model.isOwner" type="is-primary is-small" @click="transferVehicle(model)">Make Owner</b-button>
<b-button v-if="model.isShared && !model.isOwner" type="is-primary is-small" @click="transferVehicle(model)">{{ $t('makeowner') }}</b-button>
</b-field></div
></div
>

View File

@@ -1,6 +1,6 @@
<script>
import { addDays, addMonths } from 'date-fns'
import currencyFormtter from 'currency-formatter'
import currencyFormatter from 'currency-formatter'
import { mapState } from 'vuex'
import axios from 'axios'
@@ -14,12 +14,12 @@ export default {
data: function() {
return {
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' },
{ label: this.$t('thisweek'), value: 'this_week' },
{ label: this.$t('thismonth'), value: 'this_month' },
{ label: this.$tc('pastxdays', 30), value: 'past_30_days' },
{ label: this.$tc('pastxmonths', 3), value: 'past_3_months' },
{ label: this.$t('thisyear'), value: 'this_year' },
{ label: this.$t('alltime'), value: 'all_time' },
],
dateRangeOption: 'past_30_days',
stats: [],
@@ -32,15 +32,15 @@ export default {
return [
[
{
label: 'Total Expenditure',
label: this.$t('totalexpenses'),
value: this.formatCurrency(0, this.user.currency),
},
{
label: 'Fillup Costs',
label: this.$t('fillupcost'),
value: `${this.formatCurrency(0, this.user.currency)} (0)`,
},
{
label: 'Other Expenses',
label: this.$t('otherexpenses'),
value: `${this.formatCurrency(0, this.user.currency)} (0)`,
},
],
@@ -49,15 +49,15 @@ export default {
return this.stats.map((x) => {
return [
{
label: 'Total Expenditure',
label: this.$t('totalexpenses'),
value: this.formatCurrency(x.expenditureTotal, x.currency),
},
{
label: 'Fillup Costs',
label: this.$t('fillupcost'),
value: `${this.formatCurrency(x.expenditureFillups, x.currency)} (${x.countFillups})`,
},
{
label: 'Other Expenses',
label: this.$t('otherexpenses'),
value: `${this.formatCurrency(x.expenditureExpenses, x.currency)} (${x.countExpenses})`,
},
]
@@ -80,7 +80,7 @@ export default {
if (!currencyCode) {
currencyCode = this.me.currency
}
return currencyFormtter.format(number, { code: currencyCode })
return currencyFormatter.format(number, { code: currencyCode })
},
getStats() {
axios
@@ -106,6 +106,7 @@ export default {
if (currentDayOfWeek > 1) {
toSubtract = -1 * (currentDayOfWeek - 1)
}
toDate.setHours(0, 0, 0, 0)
return addDays(toDate, toSubtract)
case 'this_month':
return new Date(toDate.getFullYear(), toDate.getMonth(), 1)
@@ -114,7 +115,7 @@ export default {
case 'past_3_months':
return addMonths(toDate, -3)
case 'this_year':
return new Date(toDate.getFullYear(), 1, 1)
return new Date(toDate.getFullYear(), 0, 1)
case 'all_time':
return new Date(1969, 4, 20)
default:
@@ -128,7 +129,7 @@ export default {
<template>
<div>
<div class="columns">
<div class="column" :class="isMobile ? 'has-text-centered' : ''"> <h1 class="title">Stats</h1></div>
<div class="column" :class="isMobile ? 'has-text-centered' : ''"> <h1 class="title">{{ $t('statistics') }}</h1></div>
<div class="column">
<b-select v-model="dateRangeOption" class="is-pulled-right is-medium">
<option v-for="option in dateRangeOptions" :key="option.value" :value="option.value">

View File

@@ -10,10 +10,10 @@ $size-input-padding-vertical: 0.75em;
$size-input-padding-horizontal: 1em;
$size-input-padding: $size-input-padding-vertical $size-input-padding-horizontal;
$size-input-border: 1px;
$size-input-border-radius: (1em + $size-input-padding-vertical * 2) / 10;
$size-input-border-radius: calc((1em + $size-input-padding-vertical * 2) / 10);
// BUTTONS
$size-button-padding-vertical: $size-grid-padding / 2;
$size-button-padding-horizontal: $size-grid-padding / 1.5;
$size-button-padding-vertical: calc($size-grid-padding / 2);
$size-button-padding-horizontal: calc($size-grid-padding / 1.5);
$size-button-padding: $size-button-padding-vertical
$size-button-padding-horizontal;

View File

@@ -147,7 +147,7 @@
$max-screen,
$max-value
) {
$a: ($max-value - $min-value) / ($max-screen - $min-screen);
$a: calc(($max-value - $min-value) / ($max-screen - $min-screen));
$b: $min-value - $a * $min-screen;
$sign: '+';

25
ui/src/i18n.js Normal file
View File

@@ -0,0 +1,25 @@
import Vue from 'vue';
import VueI18n from 'vue-i18n';
Vue.use(VueI18n);
function loadLocaleMessages () {
const locales = require.context('./locales', true, /[A-Za-z0-9-_,\s]+\.json$/i)
const messages = {}
locales.keys().forEach(key => {
const matched = key.match(/([A-Za-z0-9-_]+)\./i)
if (matched && matched.length > 1) {
const locale = matched[1]
messages[locale] = locales(key)
}
})
return messages
}
const i18n = new VueI18n({
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
messages: loadLocaleMessages()
});
export default i18n;

217
ui/src/locales/de.json Normal file
View File

@@ -0,0 +1,217 @@
{
"quickentry": "Keine Schnelleinträge | Schnelleintrag | Schnelleinträge",
"statistics": "Statistiken",
"thisweek": "Diese Woche",
"thismonth": "Dieser Monat",
"pastxdays": "Letzter Tag | Letzte {count} Tage",
"pastxmonths": "Letzter Monat | Letzte {count} Monate",
"thisyear": "Dieses Jahr",
"alltime": "Gesamt",
"noattachments": "Keine Anhänge",
"attachments": "Anhänge",
"choosefile": "Datei auswählen",
"addattachment": "Anhang hinzufügen",
"sharedwith": "Geteilt mit",
"share": "Teile",
"you": "du",
"addfillup": "Tankfüllung erfassen",
"createfillup": "Erfasse Tankfüllung",
"deletefillup": "Lösche diese Tankfüllung",
"addexpense": "Ausgabe erfassen",
"createexpense": "Erfasse Ausgabe",
"deleteexpense": "Lösche diese Ausgabe",
"nofillups": "Keine Tankfüllungen",
"transfervehicle": "Fahrzeug übertragen",
"settingssaved": "Einstellungen erfolgreich gespeichert",
"yoursettings": "Deine Einstellungen",
"settings": "Einstellungen",
"changepassword": "Passwort ändern",
"oldpassword": "Bisheriges Passwort",
"newpassword": "Neues Passwort",
"repeatnewpassword": "Neues Passwort wiederholen",
"passworddontmatch": "Passwörter stimmen nicht überein",
"save": "Speichern",
"supportthedeveloper": "Unterstütze den Entwickler",
"buyhimabeer": "Kauf ihm ein Bier!",
"moreinfo": "Mehr Info",
"currency": "Währung",
"distanceunit": "Entfernungseinheit",
"dateformat": "Datumsformat",
"createnow": "Jetzt erstellen",
"yourvehicles": "Deine Fahrzeuge",
"menu": {
"quickentries": "Schnellinträge",
"logout": "Abmelden",
"import": "Import",
"home": "Start",
"settings": "Einstellungen",
"admin": "Verwalten",
"sitesettings": "Globale Einstellungen",
"users": "Benutzer",
"login": "Anmelden"
},
"enterusername": "E-Mail eingeben",
"enterpassword": "Passwort eingeben",
"email": "E-Mail",
"password": "Passwort",
"login": "Anmelden",
"totalexpenses": "Gesamtausgaben",
"fillupcost": "Tank-Ausgaben",
"otherexpenses": "Andere Ausgaben",
"addvehicle": "Fahrzeug hinzufügen",
"editvehicle": "Fahrzeug bearbeiten",
"deletevehicle": "Fahrzeug löschen",
"sharevehicle": "Fahrzeug teilen",
"makeowner": "zum Besitzer machen",
"lastfillup": "Letztes Tanken",
"quickentrydesc": "Mach ein Foto deiner Rechnung oder der Zapfsäule um den Eintrag später zu ergänzen.",
"quickentrycreatedsuccessfully": "Schnelleintrag erfolgreich erstellt",
"uploadfile": "Datei hochladen",
"uploadphoto": "Foto hochladen",
"details": "Details",
"odometer": "Kilometerzähler",
"language": "Sprache",
"date": "Datum",
"pastfillups": "Tankfüllungen",
"fuelsubtype": "Kraftstofftyp",
"fueltype": "Kraftstoff",
"quantity": "Menge",
"gasstation": "Tankstelle",
"fuel": {
"petrol": "Benzin",
"diesel": "Diesel",
"cng": "CNG",
"lpg": "LPG",
"electric": "Strom",
"ethanol": "Ethanol"
},
"unit": {
"long": {
"litre": "Liter",
"gallon": "Gallone",
"kilowatthour": "Kilowattstunde",
"kilogram": "Kilogramm",
"usgallon": "US-Gallone",
"minutes": "Minuten",
"kilometers": "Kilometer",
"miles": "Meilen"
},
"short": {
"litre": "L",
"gallon": "Gal",
"kilowatthour": "KwH",
"kilogram": "Kg",
"usgallon": "US-Gal",
"minutes": "Min",
"kilometers": "Km",
"miles": "Mi"
}
},
"avgfillupqty": "Ø Tankmenge",
"avgfillupexpense": "Ø Tankwert",
"avgfuelcost": "Ø Spritpreis",
"per": "{0} pro {1}",
"price": "Preis",
"total": "Gesamt",
"fulltank": "Voller Tank",
"getafulltank": "Hast du vollgetankt?",
"by": "Von",
"expenses": "Ausgaben",
"expensetype": "Ausgaben Typ",
"noexpenses": "Keine Ausgaben",
"download": "Herunterladen",
"title": "Titel",
"name": "Name",
"delete": "Löschen",
"importdata": "Importiere Daten in Hammond",
"importdatadesc": "Wähle eine der folgenden Optionen, um Daten in Hammond zu importieren",
"import": "Importieren",
"importcsv": "Wenn du {name} nutzt, um deine Fahrzeugdaten zu verwalten, exportiere die CSV Datei aus {name} und klicke hier, um zu importieren.",
"choosecsv": "CSV auswählen",
"choosephoto": "Foto auswählen",
"importsuccessfull": "Daten erfolgreich importiert",
"importerror": "Beim Importieren der Datei ist ein Fehler aufgetreten. Details findest du in der Fehlermeldung",
"importfrom": "Importiere von {name}",
"stepstoimport": "Schritte, um Daten aus {name} zu importieren",
"choosecsvimport": "Wähle die {name} CSV aus und klicke den Button, um zu importieren.",
"dontimportagain": "Achte darauf, dass du die Datei nicht erneut importierst, da dies zu mehrfachen Einträgen führen würde.",
"checkpointsimportcsv": "Wenn du alle diese Punkte überprüft hast kannst du unten die CSV importieren.",
"importhintunits": "Vergewissere dich ebenfalls, dass die <u>Kraftstoffeinheit</u> und der <u>Kraftstofftyp</u> im Fahrzeug richtig eingestellt sind.",
"importhintcurrdist": "Stelle sicher, dass die <u>Währung</u> und die <u>Entfernungseinheit</u> in Hammond korrekt eingestellt sind. Der Import erkennt die Währung nicht automatisch aus der datei, sondern verwendet die für den Benutzer eingestellte Währung.",
"importhintnickname": "Vergewissere dich, dass der Fahrzeugname in Hammond genau mit dem Namen in der Fuelly-CSV-Datei übereinstimmt, sonst funktioniert der Import nicht.",
"importhintvehiclecreated": "Vergewissere dich, dass du die Fahrzeuge bereits in Hammond erstellt hast.",
"importhintcreatecsv": "Exportiere deine Daten aus {name} im CSV-Format. Die Schritte dazu findest du",
"here": "hier",
"unprocessedquickentries": "Du hast einen Schnelleintrag zum bearbeiten. | Du hast {0} Schnelleinträge zum bearbeiten.",
"show": "Anzeigen",
"loginerror": "Bei der Anmeldung ist ein Fehler aufgetreten. {msg}",
"showunprocessed": "Zeige unbearbeitete",
"unprocessed": "unbearbeitet",
"sitesettingdesc": "Ändere die globalen Einstellungen. Diese werden als Standard für neue Benutzer verwendet.",
"settingdesc": "Diese Einstellungen werden als Standard verwendet wenn du eine neue Ausgabe oder eine Tankfüllung erfasst.",
"areyousure": "Bist du dir sicher?",
"adduser": "Benutzer hinzufügen",
"usercreatedsuccessfully": "Benutzer erfolgreich gespeichert",
"role": "Rolle",
"created": "Erstellt",
"createnewuser": "Erstelle neuen Benutzer",
"cancel": "Abbrechen",
"novehicles": "Du hast noch kein Fahrzeug erstellt. Lege jetzt einen Eintrag für das zu verwaltende Fahrzeug an.",
"processed": "Bearbeitet",
"notfound": "Nicht gefunden",
"timeout": "Das Laden der Seite hat eine Zeitüberschreitung verursacht. Bist du sicher, dass du noch mit dem Internet verbunden bist?",
"clicktoselect": "Klicke, um auszuwählen...",
"expenseby": "Ausgabe von",
"selectvehicle": "Wähle ein Fahrzeug aus",
"expensedate": "Datum der Ausgabe",
"totalamountpaid": "Gezahlter Gesamtbetrag",
"fillmoredetails": "Weitere Details ausfüllen",
"markquickentryprocessed": "Markiere gewählten Schnelleintrag als bearbeitet",
"referquickentry": "Wähle Schnelleintrag",
"deletequickentry": "Willst du diesen Schnelleintrag wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden!",
"fuelunit": "Kraftstoffeinheit",
"fillingstation": "Tankstelle",
"comments": "Kommentare",
"missfillupbefore": "Hast du vergessen, die vorherige Tankfüllung zu erfassen?",
"fillupdate": "Tankdatum",
"fillupsavedsuccessfully": "Tankfüllung erfolgreich gespeichert",
"expensesavedsuccessfully": "Ausgabe erfolgreich gespeichert",
"vehiclesavedsuccessfully": "Fahrzeug erfolgreich gespeichert",
"back": "Zurück",
"nickname": "Bezeichnung",
"registration": "Nummernschild",
"createvehicle": "Fahrzeug erstellen",
"make": "Marke",
"model": "Modell",
"yearmanufacture": "Jahr der Erstzulassung",
"enginesize": "Hubraum (in ccm)",
"testconn": "Teste Verbindung",
"migrate": "Migrieren",
"init": {
"migrateclarkson": "Migriere von Clarkson",
"migrateclarksondesc": "Wenn du bereits eine Instanz von Clarkson verwendest und die Daten migrieren möchtest, klicke hier.",
"freshinstall": "Frische Installation",
"freshinstalldesc": "Wenn du eine neue Installation von Hammond starten möchtest, klicke hier.",
"clarkson": {
"desc": "<p>Zuerst musst du sicherstellen, dass das Deployment von Hammond die von Clarkson verwendete MySQL Datenbank erreichen kann.</p><p>Wenn dies nicht möglich ist kannst du eine Kopie erstellen die für Hammond erreichbar ist.</p><p>Wenn das erledigt ist, füge hier den Connection String im folgenden Format ein.</p><p>Alle aus Clarkson importierten Nutzer bekommen ihren Benutzernamen als E-Mail und das Passwort wird geändert zu <span class='' style='font-weight:bold'>hammond</span></p><code>user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local</code><br/><br/>",
"success": "Deine Daten wurden erfolgreich von Clarkson migriert. Du wirst in kürze zur Anmeldung weitergeleitet wo du dich mit deiner E-Mail und dem passwort `hammond` anmelden kannst."
},
"fresh": {
"setupadminuser": "Erstelle einen Administrator",
"yourpassword": "Dein Passwort",
"youremail": "Deine E-Mail-Adresse",
"yourname": "Dein Name",
"success": "Du hast dich erfolgreich registriert. Du wirst in kürze zur Anmeldung weitergeleitet und kannst Anfangen Hammond zu verwenden."
}
},
"roles": {
"ADMIN": "Adminstrator",
"USER": "Benutzer"
},
"profile": "Profil",
"processedon": "Bearbeitet am",
"enable": "Entsperren",
"disable": "Sperren",
"confirm": "Bestätigen",
"labelforfile": "Bezeichnung für diese Datei"
}

231
ui/src/locales/en.json Normal file
View File

@@ -0,0 +1,231 @@
{
"quickentry": "No Quick Entries | Quick Entry | Quick Entries",
"statistics": "Statistics",
"thisweek": "This week",
"thismonth": "This month",
"pastxdays": "Past one day | Past {count} days",
"pastxmonths": "Past one month | Past {count} months",
"thisyear": "This year",
"alltime": "All Time",
"noattachments": "No Attachments so far",
"attachments": "Attachments",
"choosefile": "Choose File",
"addattachment": "Add Attachment",
"sharedwith": "Shared with",
"share": "Share",
"you": "You",
"addfillup": "Add Fillup",
"createfillup": "Create Fillup",
"deletefillup": "Delete this fillup",
"addexpense": "Add Expense",
"createexpense": "Create Expense",
"deleteexpense": "Delete this expense",
"nofillups": "No Fillups so far",
"transfervehicle": "Transfer Vehicle",
"settingssaved": "Settings saved successfully",
"yoursettings": "Your Settings",
"settings": "Settings",
"changepassword": "Change password",
"oldpassword": "Old password",
"newpassword": "New password",
"repeatnewpassword": "Repeat New Password",
"passworddontmatch": "Password values don't match",
"save": "Save",
"supportthedeveloper": "Support the developer",
"buyhimabeer": "Buy him a beer!",
"featurerequest": "Feature Request",
"foundabug": "Found a bug",
"currentversion": "Current Version",
"moreinfo": "More Info",
"currency": "Currency",
"distanceunit": "Distance Unit",
"dateformat": "Date Format",
"createnow": "Create Now",
"yourvehicles": "Your Vehicles",
"menu": {
"quickentries": "Quick Entries",
"logout": "Log out",
"import": "Import",
"home": "Home",
"settings": "Settings",
"admin": "Admin",
"sitesettings": "Site Settings",
"users": "Users",
"login": "Log in"
},
"enterusername": "Enter your username",
"enterpassword": "Enter your password",
"email": "Email",
"password": "Password",
"login": "log in",
"totalexpenses": "Total Expenses",
"fillupcost": "Fillup Costs",
"otherexpenses": "Other Expenses",
"addvehicle": "Add Vehicle",
"editvehicle": "Edit Vehicle",
"deletevehicle": "Delete Vehicle",
"sharevehicle": "Share vehicle",
"makeowner": "Make Owner",
"lastfillup": "Last Fillup",
"quickentrydesc": "Take a pic of the invoice or the fuel pump display to make an entry later.",
"quickentrycreatedsuccessfully": "Quick Entry Created Successfully",
"uploadfile": "Upload File",
"uploadphoto": "Upload Photo",
"details": "Details",
"odometer": "Odometer",
"language": "Language",
"date": "Date",
"pastfillups": "Past Fillups",
"fuelsubtype": "Fuel Subtype",
"fueltype": "Fuel Type",
"quantity": "Quantity",
"gasstation": "Gas Station",
"fuel": {
"petrol": "Petrol",
"diesel": "Diesel",
"cng": "CNG",
"lpg": "LPG",
"electric": "Electric",
"ethanol": "Ethanol"
},
"unit": {
"long": {
"litre": "Litre",
"gallon": "Gallon",
"kilowatthour": "Kilowatt Hour",
"kilogram": "Kilogram",
"usgallon": "US Gallon",
"minutes": "Minutes",
"kilometers": "Kilometers",
"miles": "Miles"
},
"short": {
"litre": "Lt",
"gallon": "Gal",
"kilowatthour": "KwH",
"kilogram": "Kg",
"usgallon": "US Gal",
"minutes": "Mins",
"kilometers": "Km",
"miles": "Mi"
}
},
"avgfillupqty": "Avg Fillup Qty",
"avgfillupexpense": "Avg Fillup Expense",
"avgfuelcost": "Avg Fuel Cost",
"per": "{0} per {1}",
"price": "Price",
"total": "Total",
"fulltank": "Tank Full",
"partialfillup": "Partial Fillup",
"getafulltank": "Did you get a full tank?",
"tankpartialfull": "Which do you track?",
"by": "By",
"expenses": "Expenses",
"expensetype": "Expense Type",
"noexpenses": "No Expenses so far",
"download": "Download",
"title": "Title",
"name": "Name",
"delete": "Delete",
"importdata": "Import data into Hammond",
"importdatadesc": "Choose from the following options to import data into Hammond",
"import": "Import",
"importcsv": "If you have been using {name} to store your vehicle data, export the CSV file from {name} and click here to import.",
"importgeneric": "Generic Fillups Import",
"importgenericdesc": "Fillups CSV import.",
"choosecsv": "Choose CSV",
"choosephoto": "Choose Photo",
"importsuccessfull": "Data Imported Successfully",
"importerror": "There was some issue with importing the file. Please check the error message",
"importfrom": "Import from {0}",
"stepstoimport": "Steps to import data from {name}",
"choosecsvimport": "Choose the {name} CSV and press the import button.",
"choosedatafile": "Choose the CSV file and then press the import button.",
"dontimportagain": "Make sure that you do not import the file again because that will create repeat entries.",
"checkpointsimportcsv": "Once you have checked all these points, just import the CSV below.",
"importhintunits": "Similiarly, make sure that the <u>Fuel Unit</u> and <u>Fuel Type</u> are correctly set in the Vehicle.",
"importhintcurrdist": "Make sure that the <u>Currency</u> and <u>Distance Unit</u> are set correctly in Hammond. Import will not autodetect Currency from the file but use the one set for the user.",
"importhintnickname": "Make sure that the Vehicle nickname in Hammond is exactly the same as the name on Fuelly CSV or the import will not work.",
"importhintvehiclecreated": "Make sure that you have already created the vehicles in Hammond platform.",
"importhintcreatecsv": "Export your data from {name} in the CSV format. Steps to do that can be found",
"importgenerichintdata": "Data must be in CSV format.",
"here": "here",
"unprocessedquickentries": "You have one quick entry to be processed. | You have {0} quick entries pending to be processed.",
"show": "Show",
"loginerror": "There was an error logging in to your account. {msg}",
"showunprocessed": "Show unprocessed only",
"unprocessed": "unprocessed",
"sitesettingdesc": "Update site level settings. These will be used as default values for new users.",
"settingdesc": "These will be used as default values whenever you create a new fillup or expense.",
"areyousure": "Are you sure you want to do this?",
"adduser": "Add User",
"usercreatedsuccessfully": "User Created Successfully",
"userdisabledsuccessfully": "User disabled successfully",
"userenabledsuccessfully": "User enabled successfully",
"role": "Role",
"created": "Created",
"createnewuser": "Create New User",
"cancel": "Cancel",
"novehicles": "It seems you have not yet created a vehicle in the system. Start by creating an entry for one of the vehicles you want to track.",
"processed": "Mark Processed",
"notfound": "Not Found",
"timeout": "The page timed out while loading. Are you sure you're still connected to\nthe Internet?",
"clicktoselect": "Click to select...",
"expenseby": "Expense by",
"selectvehicle": "Select a vehicle",
"expensedate": "Expense Date",
"totalamountpaid": "Total Amount Paid",
"fillmoredetails": "Fill more details",
"markquickentryprocessed": "Mark selected Quick Entry as processed",
"referquickentry": "Refer quick entry",
"deletequickentry": "This will delete this Quick Entry. This step cannot be reversed. Are you sure?",
"fuelunit": "Fuel Unit",
"fillingstation": "Filling Station Name",
"comments": "Comments",
"missfillupbefore": "Did you miss the fillup entry before this one?",
"missedfillup": "Missed Fillup",
"fillupdate": "Fillup Date",
"fillupsavedsuccessfully": "Fillup Saved Successfully",
"expensesavedsuccessfully": "Expense Saved Successfully",
"vehiclesavedsuccessfully": "Vehicle Saved Successfully",
"settingssavedsuccessfully": "Settings saved successfully",
"back": "Back",
"nickname": "Nickname",
"registration": "Registration",
"createvehicle": "Create Vehicle",
"make": "Make / Company",
"model": "Model",
"yearmanufacture": "Year of Manufacture",
"enginesize": "Engine Size (in cc)",
"mysqlconnstr": "Mysql Connection String",
"testconn": "Test Connection",
"migrate": "Migrate",
"init": {
"migrateclarkson": "Migrate from Clarkson",
"migrateclarksondesc": "If you have an existing Clarkson deployment and you want to migrate your data from that, press the following button.",
"freshinstall": "Fresh Install",
"freshinstalldesc": "If you want a fresh install of Hammond, press the following button.",
"clarkson": {
"desc": "<p>You need to make sure that this deployment of Hammond can access the MySQL database used by Clarkson.</p><p>If that is not directly possible, you can make a copy of that database somewhere accessible from this instance.</p><p>Once that is done, enter the connection string to the MySQL instance in the following format.</p><p>All the users imported from Clarkson will have their username as their email in Clarkson database and pasword set to<span class='' style='font-weight:bold'>hammond</span></p><code>user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local</code><br/><br/>",
"success": "We have successfully migrated the data from Clarkson. You will be redirected to the login screen shortly where you can login using your existing email and password : hammond"
},
"fresh": {
"setupadminuser": "Setup Admin Users",
"yourpassword": "Your Password",
"youremail": "Your Email",
"yourname": "Your Name",
"success": "You have been registered successfully. You will be redirected to the login screen shortly where you can login and start using the system."
}
},
"roles": {
"ADMIN": "ADMIN",
"USER": "USER"
},
"profile": "Profile",
"processedon": "Processed on",
"enable": "Enable",
"disable": "Disable",
"confirm": "Go Ahead",
"labelforfile": "Label for this file"
}

View File

@@ -7,6 +7,7 @@ import {
faCheck,
faTimes,
faArrowUp,
faArrowRotateLeft,
faAngleLeft,
faAngleRight,
faCalendar,
@@ -24,6 +25,7 @@ import {
faTimesCircle,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import i18n from './i18n';
import App from './app.vue'
@@ -33,11 +35,11 @@ import '@components/_globals'
import 'buefy/dist/buefy.css'
import 'nprogress/nprogress.css'
Vue.component('vue-fontawesome', FontAwesomeIcon)
library.add(
faCheck,
faTimes,
faArrowUp,
faArrowRotateLeft,
faAngleLeft,
faAngleRight,
faCalendar,
@@ -53,7 +55,9 @@ library.add(
faShare,
faUserFriends,
faTimesCircle
)
);
Vue.component('VueFontawesome', FontAwesomeIcon)
Vue.use(Buefy, {
defaultIconComponent: 'vue-fontawesome',
defaultIconPack: 'fas',
@@ -73,6 +77,7 @@ const app = new Vue({
store,
render: (h) => h(App),
i18n,
}).$mount('#app')
// If running e2e tests...

View File

@@ -410,6 +410,24 @@ export default [
},
props: (route) => ({ user: store.state.auth.currentUser || {} }),
},
{
path: '/import/drivvo',
name: 'import-drivvo',
component: () => lazyLoadView(import('@views/import-drivvo.vue')),
meta: {
authRequired: true,
},
props: (route) => ({ user: store.state.auth.currentUser || {} }),
},
{
path: '/import/generic',
name: 'import-generic',
component: () => lazyLoadView(import('@views/import-generic.vue')),
meta: {
authRequired: true,
},
props: (route) => ({ user: store.state.auth.currentUser || {} }),
},
{
path: '/logout',
name: 'logout',

View File

@@ -23,7 +23,7 @@ export default {
<template v-if="resource">
{{ resource }}
</template>
Not Found
{{ $t('notfound') }}
</h1>
</Layout>
</template>

View File

@@ -32,8 +32,7 @@ export default {
<template>
<Layout v-if="offlineConfirmed">
<h1 :class="$style.title">
The page timed out while loading. Are you sure you're still connected to
the Internet?
{{ $t('timeout') }}
</h1>
</Layout>
<LoadingView v-else />

View File

@@ -95,7 +95,7 @@ export default {
.put(`/api/vehicles/${this.selectedVehicle.id}/expenses/${this.expense.id}`, this.expenseModel)
.then((data) => {
this.$buefy.toast.open({
message: 'Expense Updated Successfully',
message: this.$t('expensesavedsuccessfully'),
type: 'is-success',
duration: 3000,
})
@@ -120,7 +120,7 @@ export default {
.post(`/api/vehicles/${this.selectedVehicle.id}/expenses`, this.expenseModel)
.then((data) => {
this.$buefy.toast.open({
message: 'Expense Created Successfully',
message: this.$t('expensesavedsuccessfully'),
type: 'is-success',
duration: 3000,
})
@@ -152,7 +152,7 @@ export default {
<Layout>
<div class="columns">
<div class="column is-two-thirds">
<h1 class="title">Create Expense</h1>
<h1 class="title">{{ $t('createexpense') }}</h1>
<h1 class="subtitle">
{{ [selectedVehicle.nickname, selectedVehicle.registration, selectedVehicle.make, selectedVehicle.model].join(' | ') }}
</h1>
@@ -162,61 +162,61 @@ export default {
</div>
</div>
<form @submit.prevent="createExpense">
<b-field label="Select a vehicle">
<b-select v-model="selectedVehicle" placeholder="Vehicle" required expanded :disabled="expense.id">
<b-field :label="$t('selectvehicle')">
<b-select v-model="selectedVehicle" :placeholder="$t('vehicle')" required expanded :disabled="expense.id">
<option v-for="option in myVehicles" :key="option.id" :value="option">
{{ option.nickname }}
</option>
</b-select>
</b-field>
<b-field label="Expense by">
<b-select v-model="expenseModel.userId" placeholder="User" required expanded :disabled="expense.id">
<b-field :label="$t('expenseby')">
<b-select v-model="expenseModel.userId" :placeholder="$t('user')" required expanded :disabled="expense.id">
<option v-for="option in users" :key="option.userId" :value="option.userId">
{{ option.name }}
</option>
</b-select>
</b-field>
<b-field label="Expense Date">
<b-field :label="$t('expensedate')">
<b-datepicker
v-model="expenseModel.date"
:date-formatter="formatDate"
placeholder="Click to select..."
:placeholder="$t('clicktoselect')"
icon="calendar"
:max-date="new Date()"
>
</b-datepicker>
</b-field>
<b-field label="Expense Type*">
<b-field :label="$t('expensetype') + `*`">
<b-input v-model="expenseModel.expenseType" expanded required></b-input>
</b-field>
<b-field label="Total Amount Paid">
<b-field :label="$t('totalamountpaid')">
<p class="control">
<span class="button is-static">{{ me.currency }}</span>
</p>
<b-input v-model.number="expenseModel.amount" type="number" min="0" expanded step=".001" required></b-input>
</b-field>
<b-field label="Odometer Reading">
<b-field :label="$t('odometer')">
<p class="control">
<span class="button is-static">{{ me.distanceUnitDetail.short }}</span>
<span class="button is-static">{{ $t('unit.short.' + me.distanceUnitDetail.key) }}</span>
</p>
<b-input v-model.number="expenseModel.odoReading" type="number" min="0" expanded required></b-input>
</b-field>
<b-field>
<b-switch v-model="showMore">Fill more details</b-switch>
<b-switch v-model="showMore">{{ $t('fillmoredetails') }}</b-switch>
</b-field>
<fieldset v-if="showMore">
<b-field label="Comments">
<b-field :label="$t('details')">
<b-input v-model="expenseModel.comments" type="textarea" expanded></b-input>
</b-field>
</fieldset>
<b-field>
<b-switch v-if="quickEntry" v-model="processQuickEntry">Mark selected Quick Entry as processed</b-switch>
<b-switch v-if="quickEntry" v-model="processQuickEntry">{{ $t('markquickentryprocessed') }}</b-switch>
</b-field>
<br />
<b-field>
<b-button tag="input" native-type="submit" :disabled="tryingToCreate" type="is-primary" label="Create Expense" expanded> </b-button>
<b-button tag="button" native-type="submit" :value="$t('save')" :disabled="tryingToCreate" type="is-primary" label="Create Expense" expanded/>
</b-field>
</form>
</Layout>

View File

@@ -76,6 +76,9 @@ export default {
this.fetchVehicleFuelSubTypes()
if (!this.fillup.id) {
this.fillupModel = this.getEmptyFillup()
if (this.vehicle.fillups.length > 0) {
this.fillupModel.odoReading = this.vehicle.fillups[0].odoReading
}
this.fillupModel.userId = this.me.id
}
},
@@ -126,7 +129,7 @@ export default {
.put(`/api/vehicles/${this.selectedVehicle.id}/fillups/${this.fillup.id}`, this.fillupModel)
.then((data) => {
this.$buefy.toast.open({
message: 'Fillup Updated Successfully',
message: this.$t('fillupsavedsuccessfully'),
type: 'is-success',
duration: 3000,
})
@@ -153,7 +156,7 @@ export default {
.post(`/api/vehicles/${this.selectedVehicle.id}/fillups`, this.fillupModel)
.then((data) => {
this.$buefy.toast.open({
message: 'Fillup Created Successfully',
message: this.$t('fillupsavedsuccessfully'),
type: 'is-success',
duration: 3000,
})
@@ -181,46 +184,44 @@ export default {
<template>
<Layout>
<div class="has-text-centered">
<div class="columns">
<div class="column is-two-thirds">
<h1 class="title">Create Fillup</h1>
<h1 class="subtitle">
{{ [selectedVehicle.nickname, selectedVehicle.registration, selectedVehicle.make, selectedVehicle.model].join(' | ') }}
</h1>
</div>
<div class="column is-one-thirds">
<QuickEntryDisplay v-model="quickEntry" :user="user" />
</div>
<div class="columns">
<div class="column is-two-thirds">
<h1 class="title">{{ $t('createfillup') }}</h1>
<h1 class="subtitle">
{{ [selectedVehicle.nickname, selectedVehicle.registration, selectedVehicle.make, selectedVehicle.model].join(' | ') }}
</h1>
</div>
<div class="column is-one-thirds">
<QuickEntryDisplay v-model="quickEntry" :user="user" />
</div>
</div>
<form class="" @submit.prevent="createFillup">
<b-field label="Select a vehicle">
<b-select v-model="selectedVehicle" placeholder="Vehicle" required expanded :disabled="fillup.id">
<b-field :label="$t('selectvehicle')">
<b-select v-model="selectedVehicle" :placeholder="$t('vehicle')" required expanded :disabled="fillup.id">
<option v-for="option in myVehicles" :key="option.id" :value="option">
{{ option.nickname }}
</option>
</b-select>
</b-field>
<b-field label="Expense by">
<b-select v-model="fillupModel.userId" placeholder="User" required expanded :disabled="fillup.id">
<b-field :label="$t('expenseby')">
<b-select v-model="fillupModel.userId" :placeholder="$t('user')" required expanded :disabled="fillup.id">
<option v-for="option in users" :key="option.userId" :value="option.userId">
{{ option.name }}
</option>
</b-select>
</b-field>
<b-field label="Fillup Date">
<b-field :label="$t('fillupdate')">
<b-datepicker
v-model="fillupModel.date"
:date-formatter="formatDate"
placeholder="Click to select..."
placeholder="this.$t('clicktoselect')"
icon="calendar"
trap-focus
:max-date="new Date()"
>
</b-datepicker>
</b-field>
<b-field label="Fuel Subtype">
<b-field :label="$t('fuelsubtype')">
<b-autocomplete
v-model="fillupModel.fuelSubType"
:data="filteredFuelSubtypes"
@@ -231,55 +232,63 @@ export default {
>
</b-autocomplete>
</b-field>
<b-field label="Quantity*" addons>
<b-field :label="$t('quantity') + `*`" addons>
<b-input v-model.number="fillupModel.fuelQuantity" type="number" step=".001" min="0" expanded required></b-input>
<b-select v-model="fillupModel.fuelUnit" placeholder="Fuel Unit" required>
<b-select v-model="fillupModel.fuelUnit" :placeholder="$t('fuelunit')" required>
<option v-for="(option, key) in fuelUnitMasters" :key="key" :value="key">
{{ option.long }}
{{ $t('unit.long.' + option.key) }}
</option>
</b-select>
</b-field>
<b-field :label="'Price per ' + vehicle.fuelUnitDetail.short + '*'"
<b-field :label="$t('per', { '0': $t('price'), '1': $t('unit.short.' + vehicle.fuelUnitDetail.key) })"
><p class="control">
<span class="button is-static">{{ me.currency }}</span>
</p>
<b-input v-model.number="fillupModel.perUnitPrice" type="number" min="0" step=".001" expanded required></b-input>
</b-field>
<b-field label="Total Amount Paid">
<b-field :label="$t('totalamountpaid')">
<p class="control">
<span class="button is-static">{{ me.currency }}</span>
</p>
<b-input v-model.number="fillupModel.totalAmount" type="number" min="0" step=".001" expanded required></b-input>
</b-field>
<b-field label="Odometer Reading">
<b-field :label="$t('odometer')">
<p class="control">
<span class="button is-static">{{ me.distanceUnitDetail.short }}</span>
<span class="button is-static">{{ $t('unit.short.' + me.distanceUnitDetail.key) }}</span>
</p>
<b-input v-model.number="fillupModel.odoReading" type="number" min="0" expanded required></b-input>
</b-field>
<b-field>
<b-checkbox v-model="fillupModel.isTankFull">Did you get a full tank?</b-checkbox>
<b-checkbox v-model="fillupModel.isTankFull">{{ $t('getafulltank') }}</b-checkbox>
</b-field>
<b-field>
<b-checkbox v-model="fillupModel.hasMissedFillup">Did you miss the fillup entry before this one?</b-checkbox>
<b-checkbox v-model="fillupModel.hasMissedFillup">{{ $t('missfillupbefore') }}</b-checkbox>
</b-field>
<b-field>
<b-switch v-model="showMore">Fill more details</b-switch>
<b-switch v-model="showMore">{{ $t('fillmoredetails') }}</b-switch>
</b-field>
<fieldset v-if="showMore">
<b-field label="Filling Station Name">
<b-field :label="$t('fillingstation')">
<b-input v-model="fillupModel.fillingStation" type="text" expanded></b-input>
</b-field>
<b-field label="Comments">
<b-field :label="$t('comments')">
<b-input v-model="fillupModel.comments" type="textarea" expanded></b-input>
</b-field>
</fieldset>
<b-field>
<b-switch v-if="quickEntry" v-model="processQuickEntry">Mark selected Quick Entry as processed</b-switch>
<b-switch v-if="quickEntry" v-model="processQuickEntry">{{ $t('markquickentryprocessed') }}</b-switch>
</b-field>
<br />
<b-field>
<b-button tag="input" native-type="submit" :disabled="tryingToCreate" type="is-primary" label="Create Fillup" expanded> </b-button>
<b-button
tag="button"
native-type="submit"
:disabled="tryingToCreate"
type="is-primary"
:value="$t('save')"
:label="$t('createfillup')"
expanded
/>
<p v-if="authError">
There was an error logging in to your account.
</p>

View File

@@ -47,6 +47,7 @@ export default {
fuelUnit: null,
fuelType: null,
registration: '',
vin: '',
nickname: '',
engineSize: null,
make: '',
@@ -58,6 +59,7 @@ export default {
fuelUnit: veh.fuelUnit,
fuelType: veh.fuelType,
registration: veh.registration,
vin: veh.vin,
nickname: veh.nickname,
engineSize: veh.engineSize,
make: veh.make,
@@ -74,7 +76,7 @@ export default {
.put(`/api/vehicles/${this.vehicle.id}`, this.vehicleModel)
.then((data) => {
this.$buefy.toast.open({
message: 'Vehicle Updated Successfully',
message: this.$t('vehiclesavedsuccessfully'),
type: 'is-success',
duration: 3000,
})
@@ -96,7 +98,7 @@ export default {
.post(`/api/vehicles`, this.vehicleModel)
.then((data) => {
this.$buefy.toast.open({
message: 'Vehicle Created Successfully',
message: this.$t('vehiclesavedsuccessfully'),
type: 'is-success',
duration: 3000,
})
@@ -123,57 +125,68 @@ export default {
<Layout>
<div class="columns">
<div class="column is-three-quarters">
<h1 class="title">Create Vehicle</h1>
<h1 class="title">{{ $t('createvehicle') }}</h1>
</div>
<div class="column is-one-quarter">
<router-link tag="b-button" type="is-primary" to="/">
Back to Vehicle
{{ $t('back') }}
</router-link>
</div>
</div>
<form @submit.prevent="createVehicle">
<b-field label="Nickname*">
<b-field :label="$t('nickname') + `*`">
<b-input v-model="vehicleModel.nickname" type="text" expanded required></b-input>
</b-field>
<b-field label="Registration*">
<b-field :label="$t('registration') + `*`">
<b-input v-model="vehicleModel.registration" type="text" expanded required></b-input>
</b-field>
<b-field label="Fuel Type*">
<b-select v-model.number="vehicleModel.fuelType" placeholder="Fuel Type" required expanded>
<b-field label="VIN">
<b-input v-model="vehicleModel.vin" type="text" expanded></b-input>
</b-field>
<b-field :label="$t('fueltype') + `*`">
<b-select v-model.number="vehicleModel.fuelType" :placeholder="$t('fueltype')" required expanded>
<option v-for="(option, key) in fuelTypeMasters" :key="key" :value="key">
{{ option.long }}
{{ $t('fuel.' + option.key) }}
</option>
</b-select>
</b-field>
<b-field label="Fuel Unit*">
<b-select v-model.number="vehicleModel.fuelUnit" placeholder="Fuel Unit" required expanded>
<b-field :label="$t('fuelunit') + `*`">
<b-select v-model.number="vehicleModel.fuelUnit" :placeholder="$t('fuelunit')" required expanded>
<option v-for="(option, key) in fuelUnitMasters" :key="key" :value="key">
{{ option.long }}
{{ $t('unit.long.' + option.key) }}
</option>
</b-select>
</b-field>
<b-field label="Make / Company*">
<b-field :label="$t('make') + `*`">
<b-input v-model="vehicleModel.make" type="text" required expanded></b-input>
</b-field>
<b-field label="Model*">
<b-field :label="$t('model') + `*`">
<b-input v-model="vehicleModel.model" type="text" required expanded></b-input>
</b-field>
<b-field label="Year Of Manufacture">
<b-field :label="$t('yearmanufacture') + `*`">
<b-input v-model.number="vehicleModel.yearOfManufacture" type="number" expanded number></b-input>
</b-field>
<b-field label="Engine Size (in cc)">
<b-field :label="$t('enginesize')">
<b-input v-model.number="vehicleModel.engineSize" type="number" expanded number></b-input>
</b-field>
<br />
<b-field>
<b-button tag="input" native-type="submit" :disabled="tryingToCreate" type="is-primary" label="Create Vehicle" expanded>
<b-button
tag="button"
native-type="submit"
:disabled="tryingToCreate"
type="is-primary"
:value="$t('save')"
:label="$t('createvehicle')"
expanded
>
<BaseIcon v-if="tryingToCreate" name="sync" spin />
</b-button>
<p v-if="authError">
There was an error logging in to your account.
{{ $t('loginerror') }}
</p>
</b-field>
</form>

View File

@@ -1,5 +1,5 @@
<script>
import currencyFormtter from 'currency-formatter'
import currencyFormatter from 'currency-formatter'
import appConfig from '@src/app.config'
import Layout from '@layouts/main.vue'
@@ -53,7 +53,7 @@ export default {
return parseAndFormatDate(date)
},
formatCurrency(number) {
return currencyFormtter.format(number, { code: this.me.currency })
return currencyFormatter.format(number, { code: this.me.currency })
},
},
}
@@ -62,14 +62,13 @@ export default {
<template>
<Layout>
<b-notification v-if="myVehicles.length === 0" type="is-warning is-light" :closable="false">
<div class="columns">
<div class="columns is-three-quarters">
<div class="column">
It seems you have not yet created a vehicle in the system. Start by creating an entry for
one of the vehicles you want to track.
{{ $t('novehicles') }}
</div>
<div class="column" :class="!isMobile ? 'has-text-right' : ''">
<div class="column is-one-quarter" :class="!isMobile ? 'has-text-right' : ''">
<b-button type="is-warning" class="" tag="router-link" :to="`/vehicles/create`"
>Create Now</b-button
>{{ $t('createnow') }}</b-button
></div
>
</div>
@@ -81,15 +80,11 @@ export default {
>
<div class="columns">
<div class="column">
{{
`You have ${unprocessedQuickEntries.length} quick ${
unprocessedQuickEntries.length === 1 ? 'entry' : 'entries'
} pending to be processed.`
}}
{{ $tc('unprocessedquickentries', unprocessedQuickEntries.length, { '0': unprocessedQuickEntries.length }) }}
</div>
<div class="column" :class="!isMobile ? 'has-text-right' : ''">
<b-button type="is-warning" class="is-small" tag="router-link" :to="`/quickEntries`"
>Process Now</b-button
>{{ $t('show') }}</b-button
></div
>
</div>
@@ -99,10 +94,10 @@ export default {
<br />
<section>
<div class="columns" :class="isMobile ? 'has-text-centered' : ''"
><div class="column is-three-quarters"> <h1 class="title">Your Vehicles</h1></div>
><div class="column is-three-quarters"> <h1 class="title">{{ $t('yourvehicles') }}</h1></div>
<div class="column is-one-quarter buttons" :class="!isMobile ? 'has-text-right' : ''">
<b-button type="is-primary" tag="router-link" :to="`/vehicles/create`"
>Add Vehicle</b-button
>{{ $t('addvehicle') }}</b-button
>
</div></div
>
@@ -125,22 +120,22 @@ export default {
<div class="content">
<table class="table">
<div class="columns">
<div class="column is-one-third">Last Fillup</div>
<div class="column is-one-third">{{ $t('lastfillup') }}</div>
<div class="column"
>{{ formatDate(vehicle.fillups[0].date) }} <br />
{{ `${formatCurrency(vehicle.fillups[0].totalAmount)}` }} ({{
`${vehicle.fillups[0].fuelQuantity} ${vehicle.fillups[0].fuelUnitDetail.short}`
`${vehicle.fillups[0].fuelQuantity} ${ $t('unit.short.' + vehicle.fillups[0].fuelUnitDetail.key) }`
}}
@ {{ `${formatCurrency(vehicle.fillups[0].perUnitPrice)}` }})</div
>
</div>
<div class="columns">
<div class="column is-one-third">Odometer</div>
<div class="column is-one-third">{{ $t('odometer') }}</div>
<div class="column">
<template v-if="vehicle.fillups.length">
{{ vehicle.fillups[0].odoReading }}&nbsp;{{
me.distanceUnitDetail.short
$t('unit.short.' + me.distanceUnitDetail.key)
}}</template
>
</div>
@@ -150,12 +145,12 @@ export default {
</div>
<footer class="card-footer">
<router-link class="card-footer-item" :to="'/vehicles/' + vehicle.id">
Details
{{ $t('details') }}
</router-link>
<router-link class="card-footer-item" :to="`/vehicles/${vehicle.id}/fillup`">
Add Fillup </router-link
{{ $t('addfillup') }} </router-link
><router-link class="card-footer-item" :to="`/vehicles/${vehicle.id}/expense`">
Add Expense
{{ $t('addexpense') }}
</router-link>
</footer>
</b-collapse>

View File

@@ -0,0 +1,172 @@
<script>
import Layout from '@layouts/main.vue'
import { mapState } from 'vuex'
import axios from 'axios'
export default {
page: {
title: 'Import Drivvo',
meta: [{ name: 'description', content: 'The Import Drivvo page.' }],
},
components: { Layout },
props: {
user: {
type: Object,
required: true,
},
},
data: function() {
return {
myVehicles: [],
file: null,
selectedVehicle: null,
tryingToCreate: false,
errors: [],
importLocation: true,
}
},
computed: {
...mapState('utils', ['isMobile']),
...mapState('vehicles', ['vehicles']),
uploadButtonLabel() {
if (this.isMobile) {
if (this.file == null) {
return 'Choose Photo'
} else {
return ''
}
} else {
if (this.file == null) {
return 'Choose CSV'
} else {
return ''
}
}
},
},
mounted() {
this.myVehicles = this.vehicles
},
methods: {
importDrivvo() {
console.log('Import from drivvo')
if (this.file == null) {
return
}
this.tryingToCreate = true
this.errorMessage = ''
const formData = new FormData()
formData.append('vehicleID', this.selectedVehicle)
formData.append('importLocation', this.importLocation)
formData.append('file', this.file, this.file.name)
axios
.post(`/api/import/drivvo`, formData)
.then((data) => {
this.$buefy.toast.open({
message: 'Data Imported Successfully',
type: 'is-success',
duration: 3000,
})
this.file = null
setTimeout(() => this.$router.push({ name: 'home' }), 1000)
})
.catch((ex) => {
this.$buefy.toast.open({
duration: 5000,
message: 'There was some issue with importing the file. Please check the error message',
position: 'is-bottom',
type: 'is-danger',
})
if (ex.response && ex.response.data.errors) {
this.errors = ex.response.data.errors
}
})
.finally(() => {
this.tryingToCreate = false
})
},
},
}
</script>
<template>
<Layout>
<div class="columns box">
<div class="column">
<h1 class="title">Import from Drivvo</h1>
</div>
</div>
<br />
<div class="columns">
<div class="column">
<p class="subtitle"> Steps to import data from Drivvo</p>
<ol>
<li>Export your data from Drivvo in the CSV format.</li>
<li>Select the vehicle the exported data is for. You may need to create the vehicle in Hammond first if you haven't already done so</li>
<li
>Make sure that the <u>Currency</u> and <u>Distance Unit</u> are set correctly in Hammond. Drivvo does not include this information in
their export, instead Hammond will use the values set for the user.</li
>
<li>Similiarly, make sure that the <u>Fuel Unit</u> and <u>Fuel Type</u> are correctly set in the Vehicle.</li>
<li>Once you have checked all these points, select the vehicle and import the CSV below.</li>
<li><b>Make sure that you do not import the file again as that will create repeat entries.</b></li>
</ol>
</div>
</div>
<p
><b>PS:</b> If you have <em>'income'</em> and <em>'trips'</em> in your export, they will not be imported to Hammond. The fields
<em>'Second fuel'</em> and <em>'Third fuel'</em> are are are also ignored as the use case for these is not understood by us. If you have a use
case for this, please open a issue on
<a href="https://github.com/akhilrex/hammond/issues">issue tracker</a>
</p>
<div class="section box">
<div class="columns is-multiline">
<div class="column is-full"> <p class="subtitle">Choose the vehicle, then select the Drivvo CSV and press the import button.</p></div>
<div class="column is-full is-flex is-align-content-center">
<form @submit.prevent="importDrivvo">
<div class="columns">
<div class="column">
<b-field label="Vehicle" label-position="on-border">
<b-select v-model="selectedVehicle" placeholder="Select Vehicle" required>
<option v-for="vehicle in myVehicles" :key="vehicle.id" :value="vehicle.id">{{ vehicle.nickname }}</option>
</b-select>
</b-field>
</div>
<div class="column">
<b-field>
<b-tooltip label="Whether to import the location for fillups and services or not." multilined>
<b-checkbox v-model="importLocation">Import Location?</b-checkbox>
</b-tooltip>
</b-field>
</div>
<div class="column">
<b-field class="file is-primary" :class="{ 'has-name': !!file }">
<b-upload v-model="file" class="file-label" accept=".csv" required>
<span class="file-cta">
<b-icon class="file-icon" icon="upload"></b-icon>
<span class="file-label">{{ uploadButtonLabel }}</span>
</span>
<span v-if="file" class="file-name" :class="isMobile ? 'file-name-mobile' : 'file-name-desktop'">
{{ file.name }}
</span>
</b-upload>
</b-field>
</div>
<div class="column">
<b-button tag="button" native-type="submit" :disabled="tryingToCreate" type="is-primary" class="control">
Import
</b-button>
</div></div
>
</form>
</div>
</div>
</div>
<b-message v-if="errors.length" type="is-danger">
<ul>
<li v-for="error in errors" :key="error">{{ error }}</li>
</ul>
</b-message>
</Layout>
</template>

View File

@@ -9,24 +9,6 @@ export default {
meta: [{ name: 'description', content: 'The Import Fuelly page.' }],
},
components: { Layout },
computed: {
...mapState('utils', ['isMobile']),
uploadButtonLabel() {
if (this.isMobile) {
if (this.file == null) {
return 'Choose Photo'
} else {
return ''
}
} else {
if (this.file == null) {
return 'Choose CSV'
} else {
return ''
}
}
},
},
props: {
user: {
type: Object,
@@ -40,6 +22,24 @@ export default {
errors: [],
}
},
computed: {
...mapState('utils', ['isMobile']),
uploadButtonLabel() {
if (this.isMobile) {
if (this.file == null) {
return this.$t('choosephoto')
} else {
return ''
}
} else {
if (this.file == null) {
return this.$t('choosecsv')
} else {
return ''
}
}
},
},
methods: {
importFuelly() {
if (this.file == null) {
@@ -53,16 +53,17 @@ export default {
.post(`/api/import/fuelly`, formData)
.then((data) => {
this.$buefy.toast.open({
message: 'Data Imported Successfully',
message: this.$t('importsuccessfull'),
type: 'is-success',
duration: 3000,
})
this.file = null
setTimeout(() => this.$router.push({ name: 'home' }), 1000)
})
.catch((ex) => {
this.$buefy.toast.open({
duration: 5000,
message: 'There was some issue with importing the file. Please check the error message',
message: this.$t('importerror'),
position: 'is-bottom',
type: 'is-danger',
})
@@ -82,39 +83,33 @@ export default {
<Layout>
<div class="columns box">
<div class="column">
<h1 class="title">Import from Fuelly</h1>
<h1 class="title">{{ $t('importfrom', { 'name': 'Fuelly' }) }}</h1>
</div>
</div>
<br />
<div class="columns">
<div class="column">
<p class="subtitle"> Steps to import data from Fuelly</p>
<p class="subtitle"> {{ $t('stepstoimport', { 'name': 'Fuelly' }) }}</p>
<ol>
<li
>Export your data from Fuelly in the CSV format. Steps to do that can be found
<a href="http://docs.fuelly.com/acar-import-export-center" target="_nofollow">here</a>.</li
>
<li>Make sure that you have already created the vehicles in Hammond platform.</li>
<li>Make sure that the Vehicle nickname in Hammond is exactly the same as the name on Fuelly CSV or the import will not work.</li>
<li
>Make sure that the <u>Currency</u> and <u>Distance Unit</u> are set correctly in Hammond. Import will not autodetect Currency from the
CSV but use the one set for the user.</li
>
<li>Similiarly, make sure that the <u>Fuel Unit</u> and <u>Fuel Type</u> are correctly set in the Vehicle.</li>
<li>Once you have checked all these points,just import the CSV below.</li>
<li><b>Make sure that you do not import the file again and that will create repeat entries.</b></li>
<li>{{ $t('importhintcreatecsv', { 'name': 'Fuelly' }) }} <a href="http://docs.fuelly.com/acar-import-export-center" target="_nofollow">{{ $t('here') }}</a>.</li>
<li>{{ $t('importhintvehiclecreated') }}</li>
<li>{{ $t('importhintnickname') }}</li>
<li v-html="$t('importhintcurrdist')"></li>
<li v-html="$t('importhintunits')"></li>
<li>{{ $t('checkpointsimportcsv') }}</li>
<li><b>{{ $t('dontimportagain') }}</b></li>
</ol>
</div>
</div>
<div class="section box">
<div class="columns">
<div class="column is-two-thirds"> <p class="subtitle">Choose the Fuelly CSV and press the import button.</p></div>
<div class="column is-two-thirds"> <p class="subtitle">{{ $t('choosecsvimport', { 'name': 'Fuelly' }) }}</p></div>
<div class="column is-one-third is-flex is-align-content-center">
<form @submit.prevent="importFuelly">
<div class="columns"
><div class="column">
<b-field class="file is-primary" :class="{ 'has-name': !!file }">
<b-upload v-model="file" class="file-label" accept=".csv">
<b-upload v-model="file" class="file-label" accept=".csv" required>
<span class="file-cta">
<b-icon class="file-icon" icon="upload"></b-icon>
<span class="file-label">{{ uploadButtonLabel }}</span>
@@ -126,8 +121,8 @@ export default {
</b-field>
</div>
<div class="column">
<b-button tag="input" native-type="submit" :disabled="tryingToCreate" type="is-primary" value="Upload File" class="control">
Import
<b-button tag="button" native-type="submit" :disabled="tryingToCreate" type="is-primary" class="control">
{{ $t('import') }}
</b-button>
</div></div
>

View File

@@ -0,0 +1,7 @@
import ImportGeneric from './import-generic'
describe('@views/import-generic', () => {
it('is a valid view', () => {
expect(ImportGeneric).toBeAViewComponent()
})
})

View File

@@ -0,0 +1,411 @@
<script>
import Layout from '@layouts/main.vue'
import { mapState } from 'vuex'
import axios from 'axios'
import Papa from 'papaparse'
export default {
page: {
title: 'Generic Import',
meta: [{ name: 'description', content: 'The Generic Import page.' }],
},
components: { Layout },
props: {
user: {
type: Object,
required: true,
},
},
data: function () {
return {
file: null,
tryingToCreate: false,
errors: [],
papaConfig: { dynamicTyping: true, skipEmptyLines: true, complete: this.assignResults },
fileData: null,
fileHeadings: null,
myVehicles: [],
selectedVehicle: null,
invertFullTank: null,
filledValueString: '',
notFilledValueString: '',
isFullTankString: false,
fileHeadingMap: {
fuelQuantity: null,
perUnitPrice: null,
totalAmount: null,
odoReading: null,
isTankFull: null,
hasMissedFillup: null,
comments: [], // [int]
fillingStation: null,
date: null,
fuelSubType: null,
},
}
},
computed: {
...mapState('utils', ['isMobile']),
...mapState('vehicles', ['vehicles']),
uploadButtonLabel() {
if (this.isMobile) {
if (this.file == null) {
return this.$t('choosephoto')
} else {
return ''
}
} else {
if (this.file == null) {
return this.$t('choosefile')
} else {
return ''
}
}
},
},
mounted() {
this.myVehicles = this.vehicles
},
methods: {
assignResults(results, file) {
this.fileData = results.data
this.fileHeadings = results.data[0]
},
parseCSV() {
if (this.file == null) {
return
}
this.errorMessage = ''
Papa.parse(this.file, this.papaConfig)
},
getUsedHeadings() {
return Object.keys(this.fileHeadingMap).filter((k) => this.fileHeadingMap[k] != null) // filter non-null properties
},
getTimezone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone
},
csvToJson() {
const data = []
const headings = this.getUsedHeadings().reduce((a, k) => ({ ...a, [k]: this.fileHeadingMap[k] }), {}) // create new object from filter
const comments = (row) => {
return this.fileHeadingMap.comments.reduce((a, fi) => {
// TODO: sanitize to prevent XSS
return `${a}${this.fileHeadings[fi]}: ${row[fi]}\n`
}, '')
}
const calculateTotal = (row) => {
return this.fileHeadingMap.totalAmount === -1
? row[this.fileHeadingMap.fuelQuantity] * row[this.fileHeadingMap.perUnitPrice]
: row[this.fileHeadingMap.totalAmount]
}
const setFullTank = (row) => {
if (row[this.fileHeadingMap.isTankFull].toLowerCase() === this.filledValueString.toLowerCase()) {
return true
} else if (row[this.fileHeadingMap.isTankFull].toLowerCase() === this.notFilledValueString.toLowerCase()) {
return false
} else {
// TODO: need to handle errors better
throw Error
}
}
for (let r = 1; r < this.fileData.length; r++) {
const row = this.fileData[r]
const item = {}
Object.keys(headings).forEach((k) => {
if (k === 'comments') {
item[k] = comments(row)
} else if (k === 'totalAmount') {
item[k] = calculateTotal(row)
} else if (k === 'isTankFull') {
if (this.isFullTankString) {
item[k] = setFullTank(row)
} else {
if (this.invertFullTank) {
item[k] = Boolean(!row[headings[k]])
} else {
item[k] = Boolean(row[headings[k]])
}
}
} else if (k === 'hasMissedFillup') {
// TODO: need to account for this field being a string
item[k] = Boolean(row[headings[k]])
} else if (k === 'date') {
item[k] = new Date(row[headings[k]]).toISOString()
} else {
item[k] = row[headings[k]]
}
})
data.push(item)
}
return data
},
importData() {
if (this.errors.length === 0) {
try {
const content = {
data: this.csvToJson(),
vehicleId: this.selectedVehicle.id,
timezone: this.getTimezone(),
}
axios
.post('/api/import/generic', content)
.then((data) => {
this.$buefy.toast.open({
message: this.$t('importsuccessfull'),
type: 'is-success',
duration: 3000,
})
setTimeout(() => this.$router.push({ name: 'home' }), 1000)
})
.catch((ex) => {
this.$buefy.toast.open({
duration: 5000,
message: this.$t('importerror'),
position: 'is-bottom',
type: 'is-danger',
})
console.log(ex)
if (ex.response && ex.response.data.error) {
this.errors.push(ex.response.data.error)
}
})
} catch (e) {
// TODO: handle error
this.errors.push(e)
}
} else {
this.errors.push('fix errors')
}
},
checkFieldString() {
const tankFull = this.fileData[1][this.fileHeadingMap.isTankFull]
if (typeof tankFull !== 'boolean' && typeof tankFull === 'string') {
this.isFullTankString = true
}
},
clearHeadingProperty(property) {
if (property === 'comments') {
this.fileHeadingMap[property] = []
} else {
this.fileHeadingMap[property] = null
}
},
},
}
</script>
<template>
<Layout>
<div class="columns box">
<div class="column">
<h1 class="title">{{ $t('importgeneric') }}</h1>
</div>
</div>
<br />
<div v-if="fileData === null" class="columns">
<div class="column">
<p class="subtitle"> {{ $t('stepstoimport', { name: 'CSV' }) }}</p>
<ol>
<!-- <li>{{ $t('importhintcreatecsv', { 'name': 'Fuelly' }) }} <a href="http://docs.fuelly.com/acar-import-export-center" target="_nofollow">{{ $t('here') }}</a>.</li> -->
<li>{{ $t('importgenerichintdata') }}</li>
<li>{{ $t('importhintvehiclecreated') }}</li>
<li v-html="$t('importhintcurrdist')"></li>
<li v-html="$t('importhintunits')"></li>
<li>
<b>{{ $t('dontimportagain') }}</b>
</li>
</ol>
</div>
</div>
<div v-if="fileData === null" class="section box">
<div class="columns">
<div class="column is-two-thirds">
<p class="subtitle">{{ $t('choosedatafile') }}</p>
</div>
<div class="column is-one-third is-flex is-align-content-center">
<form @submit.prevent="parseCSV">
<div class="columns">
<div class="column">
<b-field class="file is-primary" :class="{ 'has-name': !!file }">
<b-upload v-model="file" class="file-label" accept=".csv" required>
<span class="file-cta">
<b-icon class="file-icon" icon="upload"></b-icon>
<span class="file-label">{{ uploadButtonLabel }}</span>
</span>
<span v-if="file" class="file-name" :class="isMobile ? 'file-name-mobile' : 'file-name-desktop'">
{{ file.name }}
</span>
</b-upload>
</b-field>
</div>
<div class="column">
<b-button tag="button" native-type="submit" type="is-primary" class="control">
{{ $t('import') }}
</b-button>
</div>
</div>
</form>
</div>
</div>
</div>
<div v-else class="columns">
<div class="column">
<p class="subtitle">Map Fields</p>
<form class="" @submit.prevent="importData">
<b-field :label="$t('selectvehicle')">
<b-select v-model="selectedVehicle" :placeholder="$t('vehicle')" required expanded>
<option v-for="option in myVehicles" :key="option.id" :value="option">
{{ option.nickname }}
</option>
</b-select>
</b-field>
<span v-if="selectedVehicle !== null">
<b-field :label="$t('fillupdate')">
<b-select v-model="fileHeadingMap.date" required expanded>
<option v-for="(option, index) in fileHeadings" :key="index" :value="index">
{{ option }}
</option>
</b-select>
</b-field>
<b-field>
<template v-slot:label>
{{ $t('fuelsubtype') }}
<b-tooltip type="is-dark" label="Clear selection">
<b-button
type="is-ghost"
size="is-small"
icon-pack="fas"
icon-right="arrow-rotate-left"
@click="clearHeadingProperty('fuelSubType')"
></b-button>
</b-tooltip>
</template>
<b-select v-model="fileHeadingMap.fuelSubType" expanded>
<option v-for="(option, index) in fileHeadings" :key="index" :value="index">
{{ option }}
</option>
</b-select>
</b-field>
<b-field :label="$t('quantity')">
<b-select v-model="fileHeadingMap.fuelQuantity" expanded required>
<option v-for="(option, index) in fileHeadings" :key="index" :value="index">
{{ option }}
</option>
</b-select>
</b-field>
<b-field :label="$t('per', { '0': $t('price'), '1': $t('unit.short.' + selectedVehicle.fuelUnitDetail.key) })">
<b-select v-model.number="fileHeadingMap.perUnitPrice" type="number" min="0" step=".001" expanded required>
<option v-for="(option, index) in fileHeadings" :key="index" :value="index">
{{ option }}
</option>
</b-select>
</b-field>
<b-field :label="$t('totalamountpaid')">
<b-select v-model.number="fileHeadingMap.totalAmount" expanded required>
<option value="-1">Calculated</option>
<option v-for="(option, index) in fileHeadings" :key="index" :value="index">
{{ option }}
</option>
</b-select>
</b-field>
<b-field :label="$t('odometer')">
<b-select v-model.number="fileHeadingMap.odoReading" expanded required>
<option v-for="(option, index) in fileHeadings" :key="index" :value="index">
{{ option }}
</option>
</b-select>
</b-field>
<b-field :label="$t('tankpartialfull')">
<b-radio-button v-model="invertFullTank" native-value="false">{{ $t('fulltank') }}</b-radio-button>
<b-radio-button v-model="invertFullTank" native-value="true">{{ $t('partialfillup') }}</b-radio-button>
</b-field>
<b-field>
<b-select v-model="fileHeadingMap.isTankFull" required @input="checkFieldString">
<option v-for="(option, index) in fileHeadings" :key="index" :value="index">
{{ option }}
</option>
</b-select>
</b-field>
<span v-if="isFullTankString === true" required>
<b-field label="Value when tank is filled">
<b-input v-model="filledValueString"></b-input>
</b-field>
<b-field label="Value when tank was not completely filled">
<b-input v-model="notFilledValueString"></b-input>
</b-field>
</span>
<b-field>
<template v-slot:label>
{{ $t('missedfillup') }}
<b-tooltip type="is-dark" label="Clear selection">
<b-button
type="is-ghost"
size="is-small"
icon-pack="fas"
icon-right="arrow-rotate-left"
@click="clearHeadingProperty('hasMissedFillup')"
></b-button>
</b-tooltip>
</template>
<b-select v-model="fileHeadingMap.hasMissedFillup">
<option v-for="(option, index) in fileHeadings" :key="index" :value="index">
{{ option }}
</option>
</b-select>
</b-field>
<b-field>
<template v-slot:label>
{{ $t('fillingstation') }}
<b-tooltip type="is-dark" label="Clear selection">
<b-button
type="is-ghost"
size="is-small"
icon-pack="fas"
icon-right="arrow-rotate-left"
@click="clearHeadingProperty('fillingStation')"
></b-button>
</b-tooltip>
</template>
<b-select v-model="fileHeadingMap.fillingStation">
<option v-for="(option, index) in fileHeadings" :key="index" :value="index">
{{ option }}
</option>
</b-select>
</b-field>
<b-field>
<template v-slot:label>
{{ $t('comments') }}
<b-tooltip type="is-dark" label="Clear selection">
<b-button
type="is-ghost"
size="is-small"
icon-pack="fas"
icon-right="arrow-rotate-left"
@click="clearHeadingProperty('comments')"
></b-button>
</b-tooltip>
</template>
<b-select v-model="fileHeadingMap.comments" type="textarea" multiple expanded>
<option v-for="(option, index) in fileHeadings" :key="index" :value="index">
{{ option }}
</option>
</b-select>
</b-field>
<br />
<b-field>
<b-button tag="button" native-type="submit" type="is-primary" :value="$t('save')" :label="$t('import')" expanded />
<p v-if="authError"> There was an error logging in to your account. </p>
</b-field>
</span>
</form>
</div>
</div>
<b-message v-if="errors.length" type="is-danger">
<ul>
<li v-for="error in errors" :key="error">{{ error }}</li>
</ul>
</b-message>
</Layout>
</template>

View File

@@ -18,19 +18,41 @@ export default {
<template>
<Layout>
<div class="columns box"
><div class="column">
<h1 class="title">Import data into Hammond</h1>
<p class="subtitle">Choose from the following options to import data into Fuelly</p>
</div></div
>
<div class="columns box">
<div class="column">
<h1 class="title">{{ $t('importdata') }}</h1>
<p class="subtitle">{{ $t('importdatadesc') }}</p>
</div>
</div>
<br />
<div class="columns">
<div class="box column is-one-third" to="/import-fuelly">
<h1 class="title">Fuelly</h1>
<p>If you have been using Fuelly to store your vehicle data, export the CSV file from Fuelly and click here to import.</p>
<br />
<b-button type="is-primary" tag="router-link" to="/import/fuelly">Import</b-button>
<div class="column is-one-third">
<div class="box">
<h1 class="title">Fuelly</h1>
<p>If you have been using Fuelly to store your vehicle data, export the CSV file from Fuelly and click here to
import.</p>
<br />
<b-button type="is-primary" tag="router-link" to="/import/fuelly">{{ $t('import') }}</b-button>
</div>
</div>
<div class="column is-one-third" to="/import-fuelly">
<div class="box">
<h1 class="title">Drivvo</h1>
<p>{{ $t('importcsv', { 'name': 'Fuelly' }) }}</p>
<br />
<b-button type="is-primary" tag="router-link" to="/import/drivvo">{{ $t('import') }}</b-button>
</div>
</div>
<div class="column is-one-third" to="/import-generic">
<div class="box">
<h1 class="title">{{ $t('importgeneric') }}</h1>
<p>{{ $t('importgenericdesc') }}</p>
<br />
<b-button type="is-primary" tag="router-link" to="/import/generic">{{ $t('import') }}</b-button>
</div>
</div>
</div>
</Layout>

View File

@@ -21,13 +21,27 @@ export default {
email: '',
password: '',
distanceUnit: 1,
currency: 'INR',
currency: '',
},
}
},
computed: {
...mapGetters('auth', ['isInitialized']),
...mapState('vehicles', ['currencyMasters', 'distanceUnitMasters']),
filteredCurrencyMasters() {
return this.currencyMasters.filter((option) => {
return (
option.namePlural
.toString()
.toLowerCase()
.indexOf(this.registerModel.currency.toLowerCase()) >= 0 ||
option.code
.toString()
.toLowerCase()
.indexOf(this.registerModel.currency.toLowerCase()) >= 0
)
})
},
},
mounted() {
store.dispatch('vehicles/fetchMasters').then((data) => {})
@@ -48,11 +62,11 @@ export default {
var message = ''
if (this.migrationMode === 'clarkson') {
message =
'We have successfully migrated the data from Clarkson. You will be redirected to the login screen shortly where you can login using your existing email and password : hammond'
this.$t('init.clarkson.success')
}
if (this.migrationMode === 'fresh') {
message =
'You have been registered successfully. You will be redirected to the login screen shortly where you can login and start using the system.'
this.$t('init.fresh.success')
}
this.$buefy.toast.open({
duration: 10000,
@@ -139,6 +153,9 @@ export default {
})
.finally(() => (this.isWorking = false))
},
formatCurrency(option) {
return `${option.namePlural} (${option.code})`
},
},
}
</script>
@@ -146,114 +163,76 @@ export default {
<template>
<Layout>
<div v-if="!migrationMode" class="box">
<h1 class="title">Migrate from Clarkson</h1>
<h1 class="title">{{ $t('init.migrateclarkson') }}</h1>
<p>
If you have an existing Clarkson deployment and you want to migrate your data from that,
press the following button.
{{ $t('init.migrateclarksondesc') }}
</p>
<br />
<b-field>
<b-button type="is-primary" @click="migrationMode = 'clarkson'"
>Migrate from Clarkson</b-button
></b-field
>
<b-field> <b-button type="is-primary" @click="migrationMode = 'clarkson'">{{ $t('init.migrateclarkson') }}</b-button></b-field>
</div>
<div v-if="!migrationMode" class="box">
<h1 class="title">Fresh Install</h1>
<h1 class="title">{{ $t('init.freshinstall') }}</h1>
<p>
If you want a fresh install of Hammond, press the following button.
{{ $t('init.freshinstalldesc') }}
</p>
<br />
<b-field>
<b-button type="is-primary" @click="migrationMode = 'fresh'">Fresh Install</b-button>
<b-button type="is-primary" @click="migrationMode = 'fresh'">{{ $t('init.freshinstall') }}</b-button>
</b-field>
</div>
<div v-if="migrationMode === 'clarkson'" class="box content">
<h1 class="title">Migrate from Clarkson</h1>
<p
>You need to make sure that this deployment of Hammond can access the MySQL database used by
Clarkson.</p
>
<p
>If that is not directly possible, you can make a copy of that database somewhere accessible
from this instance.</p
>
<p
>Once that is done, enter the connection string to the MySQL instance in the following
format.</p
>
<p
>All the users imported from Clarkson will have their username as their email in Clarkson
database and pasword set to <span class="" style="font-weight:bold">hammond</span></p
>
<code>
user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
</code>
<br />
<br />
<h1 class="title">{{ $t('init.migrateclarkson') }}</h1>
<p v-html="$t('init.clarkson.desc')"></p>
<b-notification v-if="connectionError" type="is-danger" role="alert" :closable="false">
{{ connectionError }}
</b-notification>
<b-field addons label="Mysql Connection String">
<b-field addons :label="$t('mysqlconnstr')">
<b-input v-model="url" required></b-input>
</b-field>
<div class="buttons">
<b-button
v-if="!testSuccess"
type="is-primary"
:disabled="isWorking"
@click="testConnection"
>Test Connection</b-button
><b-button v-if="testSuccess" type="is-success" :disabled="isWorking" @click="migrate"
>Migrate</b-button
>
<b-button type="is-danger is-light" @click="resetMigrationMode">Cancel</b-button>
<b-button v-if="!testSuccess" type="is-primary" :disabled="isWorking" @click="testConnection">{{ $t('testconn') }}</b-button>
<b-button v-if="testSuccess" type="is-success" :disabled="isWorking" @click="migrate">{{ $t('migrate') }}</b-button>
<b-button type="is-danger is-light" @click="resetMigrationMode">{{ $t('cancel') }}</b-button>
</div>
</div>
<div v-if="migrationMode === 'fresh'" class="box content">
<h1 class="title">Setup Admin Users</h1>
<h1 class="title">{{ $t('init.fresh.setupadminuser') }}</h1>
<form @submit.prevent="register">
<b-field label="Your Name">
<b-field :label="$t('init.fresh.yourname')">
<b-input v-model="registerModel.name" required></b-input>
</b-field>
<b-field label="Your Email">
<b-field :label="$t('init.fresh.youremail')">
<b-input v-model="registerModel.email" type="email" required></b-input>
</b-field>
<b-field label="Your Password">
<b-input
v-model="registerModel.password"
type="password"
required
minlength="8"
password-reveal
></b-input>
<b-field :label="$t('init.fresh.yourpassword')">
<b-input v-model="registerModel.password" type="password" required minlength="8" password-reveal></b-input>
</b-field>
<b-field label="Currency">
<b-select v-model="registerModel.currency" placeholder="Currency" required expanded>
<option v-for="option in currencyMasters" :key="option.code" :value="option.code">
{{ `${option.namePlural} (${option.code})` }}
</option>
</b-select>
</b-field>
<b-field label="Distance Unit">
<b-select
v-model.number="registerModel.distanceUnit"
placeholder="Distance Unit"
<b-field :label="$t('currency')">
<b-autocomplete
v-model="registerModel.currency"
:custom-formatter="formatCurrency"
:placeholder="$t('currency')"
:data="filteredCurrencyMasters"
:keep-first="true"
:open-on-focus="true"
required
expanded
>
@select="(option) => (selected = option)"
></b-autocomplete>
</b-field>
<b-field :label="$t('distanceunit')">
<b-select v-model.number="registerModel.distanceUnit" :placeholder="$t('distanceunit')" required expanded>
<option v-for="(option, key) in distanceUnitMasters" :key="key" :value="key">
{{ `${option.long} (${option.short})` }}
{{ `${$t('unit.long.' + option.key)} (${$t('unit.short.' + option.key)})` }}
</option>
</b-select>
</b-field>
<br />
<div class="buttons">
<b-button type="is-primary" native-type="submit" tag="input"></b-button>
<b-button type="is-primary" native-type="submit" tag="button" :value="$t('save')"></b-button>
<b-button type="is-danger is-light" @click="resetMigrationMode">Cancel</b-button>
<b-button type="is-danger is-light" @click="resetMigrationMode">{{ $t('cancel') }}</b-button>
</div>
</form>
</div>

View File

@@ -16,7 +16,7 @@ export default {
password: '',
authError: null,
tryingToLogIn: false,
errorMessage:''
errorMessage: '',
}
},
computed: {
@@ -24,8 +24,8 @@ export default {
return process.env.NODE_ENV === 'production'
? {}
: {
username: 'Enter your username',
password: 'Enter your password',
username: this.$t('enterusername'),
password: this.$t('enterpassword'),
}
},
},
@@ -38,7 +38,7 @@ export default {
// and password they provided.
tryToLogIn() {
this.tryingToLogIn = true
this.errorMessage='';
this.errorMessage = ''
// Reset the authError if it existed.
this.authError = null
return this.logIn({
@@ -53,9 +53,9 @@ export default {
// Redirect to the originally requested page, or to the home page
})
.catch((error) => {
if(error.response.data?.errors?.login){
this.errorMessage=error.response.data.errors.login
}
if (error.response.data?.errors?.login) {
this.errorMessage = error.response.data.errors.login
}
this.tryingToLogIn = false
this.authError = error
})
@@ -67,31 +67,17 @@ export default {
<template>
<Layout>
<form @submit.prevent="tryToLogIn">
<b-field label="Email">
<b-input
v-model="username"
tag="b-input"
name="username"
:placeholder="placeholders.username"
/></b-field>
<b-field label="Password">
<b-input
v-model="password"
tag="b-input"
name="password"
type="password"
:placeholder="placeholders.password"
/>
<b-field :label="$t('email')"> <b-input v-model="username" tag="b-input" name="username" type="email" :placeholder="placeholders.username"/></b-field>
<b-field :label="$t('password')">
<b-input v-model="password" tag="b-input" name="password" type="password" :placeholder="placeholders.password" />
</b-field>
<b-button tag="input" native-type="submit" :disabled="tryingToLogIn" type="is-primary">
<b-button tag="button" native-type="submit" :disabled="tryingToLogIn" type="is-primary">
<BaseIcon v-if="tryingToLogIn" name="sync" spin />
<span v-else>
Log in
{{ $t('login') }}
</span>
</b-button>
<p v-if="authError">
There was an error logging in to your account. {{errorMessage}}
</p>
<p v-if="authError"> {{ $t('loginerror', { msg: errorMessage }) }}</p>
</form>
</Layout>
</template>

View File

@@ -28,7 +28,7 @@ export default {
<h1>
<BaseIcon name="user" />
{{ user.name }}
Profile
{{ $t('profile') }}
</h1>
<pre>{{ user }}</pre>
</Layout>

View File

@@ -41,7 +41,7 @@ export default {
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?')
var sure = confirm(this.$t('deletequickentry'))
if (sure) {
store.dispatch('vehicles/deleteQuickEntry', { id: entry.id }).then((data) => {})
}
@@ -59,9 +59,9 @@ export default {
<template>
<Layout>
<h1 class="title">Quick Entries</h1>
<h1 class="title">{{ $tc('quickentry', 2) }}</h1>
<b-field>
<b-switch v-if="unprocessedQuickEntries.length" v-model="showUnprocessedOnly">Show unprocessed only</b-switch>
<b-switch v-if="unprocessedQuickEntries.length" v-model="showUnprocessedOnly">{{ $t('showunprocessed') }}</b-switch>
</b-field>
<div v-for="(chunk, index) in chunkedQuickEntries" :key="index" class="tile is-ancestor">
<div v-for="entry in chunk" :key="entry.id" class="tile is-parent" :class="{ 'is-4': quickEntries.length <= 3 }">
@@ -71,7 +71,7 @@ export default {
<div class="card-header-title">
{{ parseAndFormatDateTime(entry.createdAt) }}
</div>
<b-tag v-if="entry.processDate === null" class="is-align-content-center" type="is-primary">unprocessed</b-tag>
<b-tag v-if="entry.processDate === null" class="is-align-content-center" type="is-primary">{{ $t('unprocessed') }}</b-tag>
</div>
<div class="card-image">
<!-- prettier-ignore -->
@@ -87,22 +87,22 @@ export default {
>
<footer class="card-footer">
<router-link v-if="entry.processDate === null && vehicles.length" :to="`/vehicles/${vehicles[0].id}/fillup`" class="card-footer-item"
>Create Fillup</router-link
>{{ $t('addfillup') }}</router-link
>
<router-link v-if="entry.processDate === null && vehicles.length" :to="`/vehicles/${vehicles[0].id}/expense`" class="card-footer-item"
>Create Expense</router-link
>{{ $t('addexpense') }}</router-link
>
<a v-if="entry.processDate === null" class="card-footer-item" @click="markProcessed(entry)">Mark Processed</a>
<p v-else class="card-footer-item">Processed on {{ parseAndFormatDateTime(entry.processDate) }}</p>
<a class="card-footer-item" type="is-danger" @click="deleteQuickEntry(entry)"> Delete</a>
<a v-if="entry.processDate === null" class="card-footer-item" @click="markProcessed(entry)">{{ $t('processed') }}</a>
<p v-else class="card-footer-item">{{ $t('processedon') }} {{ parseAndFormatDateTime(entry.processDate) }}</p>
<a class="card-footer-item" type="is-danger" @click="deleteQuickEntry(entry)"> {{ $t('delete') }}</a>
</footer>
</div>
</div>
</div>
</div>
<div v-if="!quickEntries.length" class="box">
<p>No Quick Entries right now.</p>
<p>{{ $tc('quickentry',0) }}</p>
</div>
</Layout>
</template>

View File

@@ -44,6 +44,20 @@ export default {
return this.changePassModel.new === this.changePassModel.renew
},
filteredCurrencyMasters() {
return this.currencyMasters.filter((option) => {
return (
option.namePlural
.toString()
.toLowerCase()
.indexOf(this.settingsModel.currency.toLowerCase()) >= 0 ||
option.code
.toString()
.toLowerCase()
.indexOf(this.settingsModel.currency.toLowerCase()) >= 0
)
})
},
},
methods: {
changePassword() {
@@ -92,7 +106,7 @@ export default {
.dispatch(`utils/saveUserSettings`, { settings: this.settingsModel })
.then((data) => {
this.$buefy.toast.open({
message: 'Settings saved successfully',
message: this.$t('settingssaved'),
type: 'is-success',
duration: 3000,
})
@@ -109,34 +123,42 @@ export default {
this.tryingToSave = false
})
},
formatCurrency(option) {
return `${option.namePlural} (${option.code})`
},
},
}
</script>
<template>
<Layout>
<h1 class="title">Your Settings</h1>
<h1 class="title">{{ $t('yoursettings') }}</h1>
<div class="columns"
><div class="column">
<form class="box " @submit.prevent="saveSettings">
<h1 class="subtitle">
These will be used as default values whenever you create a new fillup or expense.
{{ $t('settingdesc') }}
</h1>
<b-field label="Currency">
<b-select v-model="settingsModel.currency" placeholder="Currency" required expanded>
<option v-for="option in currencyMasters" :key="option.code" :value="option.code">
{{ `${option.namePlural} (${option.code})` }}
</option>
</b-select>
<b-field :label="$t('currency')">
<b-autocomplete
v-model="settingsModel.currency"
:custom-formatter="formatCurrency"
:placeholder="$t('currency')"
:data="filteredCurrencyMasters"
:keep-first="true"
:open-on-focus="true"
required
@select="(option) => (selected = option)"
></b-autocomplete>
</b-field>
<b-field label="Distance Unit">
<b-field :label="$t('distanceunit')">
<b-select v-model.number="settingsModel.distanceUnit" placeholder="Distance Unit" required expanded>
<option v-for="(option, key) in distanceUnitMasters" :key="key" :value="key">
{{ `${option.long} (${option.short})` }}
{{ `${$t('unit.long.' + option.key)} (${$t('unit.short.' + option.key)})` }}
</option>
</b-select>
</b-field>
<b-field label="Date Format">
<b-field :label="$t('dateformat')">
<b-select v-model.number="settingsModel.dateFormat" placeholder="Date Format" required expanded>
<option v-for="option in dateFormatMasters" :key="option" :value="option">
{{ `${option}` }}
@@ -145,25 +167,27 @@ export default {
</b-field>
<br />
<b-field>
<b-button tag="input" native-type="submit" :disabled="tryingToSave" type="is-primary" value="Save" expanded> </b-button>
<b-button tag="button" native-type="submit" :disabled="tryingToSave" type="is-primary" expanded> {{ $t('save') }} </b-button>
</b-field>
</form>
</div>
<div class="column">
<form class="box" @submit.prevent="changePassword">
<h1 class="subtitle">Change password</h1>
<b-field label="Old Password">
<h1 class="subtitle">{{ $t('changepassword') }}</h1>
<b-field :label="$t('oldpassword')">
<b-input v-model="changePassModel.old" required minlength="6" password-reveal type="password"></b-input>
</b-field>
<b-field label="New Password">
<b-field :label="$t('newpassword')">
<b-input v-model="changePassModel.new" required minlength="6" password-reveal type="password"></b-input>
</b-field>
<b-field label="Repeat New Password">
<b-field :label="$t('repeatnewpassword')">
<b-input v-model="changePassModel.renew" required minlength="6" password-reveal type="password"></b-input>
</b-field>
<p v-if="!passwordValid" class="help is-danger">Password values don't match</p>
<p v-if="!passwordValid" class="help is-danger">{{ $t('passworddontmatch') }}</p>
<b-field>
<b-button tag="input" native-type="submit" :disabled="!passwordValid" type="is-primary" value="Change Password" expanded> </b-button>
<b-button tag="button" native-type="submit" :disabled="!passwordValid" type="is-primary" expanded>
{{ $t('changepassword') }}
</b-button>
</b-field>
</form>
</div>
@@ -171,48 +195,32 @@ export default {
<hr />
<div class="columns">
<div class="twelve">
<h3 class="title">More Info</h3>
<p style="font-style: italic;">
This project is under active development which means I release new updates very frequently. I will eventually build the version
management/update checking mechanism. Until then it is recommended that you use something like watchtower which will automatically update
your containers whenever I release a new version or periodically rebuild the container with the latest image manually.
</p>
<br />
<h3 class="title">{{ $t('moreinfo') }}</h3>
<table class="table is-hoverable">
<tr>
<td>Current Version</td>
<td>2021.09.20</td>
<td>{{ $t('currentversion') }}</td>
<td>2022.07.06</td>
</tr>
<tr>
<td>Website</td>
<td><a href="https://github.com/akhilrex/hammond" target="_blank">https://github.com/akhilrex/hammond</a></td>
<td><a href="https://github.com/alfhou/hammond" target="_blank">https://github.com/alfhou/hammond</a></td>
</tr>
<tr>
<td>Found a bug</td>
<td>{{ $t('foundabug') }}</td>
<td
><a
href="https://github.com/akhilrex/hammond/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc"
target="_blank"
rel="noopener noreferrer"
><a href="https://github.com/alfhou/hammond/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc" target="_blank" rel="noopener noreferrer"
>Report here</a
></td
>
</tr>
<tr>
<td>Feature Request</td>
<td>{{ $t('featurerequest') }}</td>
<td
><a
href="https://github.com/akhilrex/hammond/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc"
target="_blank"
rel="noopener noreferrer"
><a href="https://github.com/alfhou/hammond/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc" target="_blank" rel="noopener noreferrer"
>Request here</a
></td
>
</tr>
<tr>
<td>Support the developer</td>
<td><a href="https://www.buymeacoffee.com/akhilrex" target="_blank" rel="noopener noreferrer">Buy him a beer!</a></td>
</tr>
</table>
</div>
</div>

View File

@@ -37,7 +37,7 @@ export default {
.dispatch(`utils/saveSettings`, { settings: this.settingsModel })
.then((data) => {
this.$buefy.toast.open({
message: 'Settings saved successfully',
message: this.$t('settingssaved'),
type: 'is-success',
duration: 3000,
})
@@ -63,32 +63,32 @@ export default {
<div class="">
<div class="columns">
<div class="column">
<h1 class="title">Site Settings</h1>
<h1 class="title">{{ $t('menu.sitesettings') }}</h1>
<h1 class="subtitle">
Update site level settings. These will be used as default values for new users.
{{ $t('sitesettingdesc') }}
</h1>
</div>
</div>
</div>
<br />
<form class="" @submit.prevent="saveSettings">
<b-field label="Currency">
<b-select v-model="settingsModel.currency" placeholder="Currency" required expanded>
<b-field :label="$t('currency')">
<b-select v-model="settingsModel.currency" :placeholder="$t('currency')" required expanded>
<option v-for="option in currencyMasters" :key="option.code" :value="option.code">
{{ `${option.namePlural} (${option.code})` }}
</option>
</b-select>
</b-field>
<b-field label="Distance Unit">
<b-select v-model.number="settingsModel.distanceUnit" placeholder="Distance Unit" required expanded>
<b-field :label="$t('distanceunit')">
<b-select v-model.number="settingsModel.distanceUnit" :placeholder="$t('distanceunit')" required expanded>
<option v-for="(option, key) in distanceUnitMasters" :key="key" :value="key">
{{ `${option.long} (${option.short})` }}
{{ `${$t('unit.long.' + option.key)} (${$t('unit.short.' + option.key)})` }}
</option>
</b-select>
</b-field>
<br />
<b-field>
<b-button tag="input" native-type="submit" :disabled="tryingToSave" type="is-primary" value="Save" expanded> </b-button>
<b-button tag="button" native-type="submit" :disabled="tryingToSave" type="is-primary" expanded> {{ $t('save') }}</b-button>
</b-field>
</form>
</Layout>

View File

@@ -55,18 +55,18 @@ export default {
},
changeDisabledStatus(userId,status){
this.$buefy.dialog.confirm({
title: status?'Disable User':"Enable User",
message: 'Are you sure you want to do this?',
cancelText: 'Cancel',
confirmText: 'Go Ahead',
title: status ? this.$t('disable') : this.$t('enable'),
message: this.$t('areyousure'),
cancelText: this.$t('cancel'),
confirmText: this.$t('confirm'),
onConfirm: () => {
var url = `/api/users/${userId}/${status?"disable":"enable"}`
var url = `/api/users/${userId}/${status ? "disable" : "enable"}`
axios
.post(url, {})
.then((data) => {
this.$buefy.toast.open({
message: status?"User disabled successfully":'User enabled successfully',
message: status ? this.$t('userdisabledsuccessfully') : this.$t('userenabledsuccessfully'),
type: 'is-success',
duration: 3000,
})
@@ -103,7 +103,7 @@ export default {
if (success) {
this.$buefy.toast.open({
duration: 10000,
message: 'User Created Successfully',
message: this.$t('usercreatedsuccessfully'),
position: 'is-bottom',
type: 'is-success',
})
@@ -129,22 +129,22 @@ export default {
<Layout>
<div class="box">
<div class="columns">
<div class="column is-three-quarters"> <h1 class="title is-4">Users</h1> </div>
<div class="column is-three-quarters"> <h1 class="title is-4">{{ $t('menu.users') }}</h1> </div>
<div class="column is-one-quarter">
<b-button type="is-primary" @click="showUserForm = true">Add User</b-button>
<b-button type="is-primary" @click="showUserForm = true">{{ $t('adduser') }}</b-button>
</div>
</div>
<div v-if="showUserForm" class="box content">
<h1 class="title">Create New User</h1>
<h1 class="title">{{ $t('createnewuser') }}</h1>
<form @submit.prevent="register">
<b-field label="Name">
<b-field :label="$t('name')">
<b-input v-model="registerModel.name" required></b-input>
</b-field>
<b-field label="Email">
<b-field :label="$t('email')">
<b-input v-model="registerModel.email" type="email" required></b-input>
</b-field>
<b-field label="Password">
<b-field :label="$t('password')">
<b-input
v-model="registerModel.password"
type="password"
@@ -153,56 +153,56 @@ export default {
password-reveal
></b-input>
</b-field>
<b-field label="Role">
<b-select v-model.number="registerModel.role" placeholder="Role" required expanded>
<b-field :label="$t('role')">
<b-select v-model.number="registerModel.role" :placeholder="$t('role')" required expanded>
<option v-for="(option, key) in roleMasters" :key="key" :value="key">
{{ `${option.long}` }}
{{ `${option.key}` }}
</option>
</b-select>
</b-field>
<b-field label="Currency">
<b-select v-model="registerModel.currency" placeholder="Currency" required expanded>
<b-field :label="$t('currency')">
<b-select v-model="registerModel.currency" :placeholder="$t('currency')" required expanded>
<option v-for="option in currencyMasters" :key="option.code" :value="option.code">
{{ `${option.namePlural} (${option.code})` }}
</option>
</b-select>
</b-field>
<b-field label="Distance Unit">
<b-field :label="$t('distanceunit')">
<b-select
v-model.number="registerModel.distanceUnit"
placeholder="Distance Unit"
:placeholder="$t('distanceunit')"
required
expanded
>
<option v-for="(option, key) in distanceUnitMasters" :key="key" :value="key">
{{ `${option.long} (${option.short})` }}
{{ `${$t('unit.long.' + option.key)} (${$t('unit.short.' + option.key)})` }}
</option>
</b-select>
</b-field>
<br />
<div class="buttons">
<b-button type="is-primary" native-type="submit" tag="input"></b-button>
<b-button type="is-primary" native-type="submit" tag="button">{{ $t('save') }}</b-button>
<b-button type="is-danger is-light" @click="resetUserForm">Cancel</b-button>
<b-button type="is-danger is-light" @click="resetUserForm">{{ $t('cancel') }}</b-button>
</div>
</form>
</div>
<b-table :data="users" hoverable mobile-cards detail-key="id" paginated per-page="10" :row-class="(row, index) => row.isDisabled && 'is-disabled'">
<b-table-column v-slot="props" field="name" label="Name">
{{ `${props.row.name}` }} <template v-if="props.row.id === user.id">(You)</template>
<b-table-column v-slot="props" field="name" :label="$t('name')">
{{ `${props.row.name}` }} <template v-if="props.row.id === user.id">({{ $t('you') }})</template>
</b-table-column>
<b-table-column v-slot="props" field="email" label="Email">
<b-table-column v-slot="props" field="email" :label="$t('email')">
{{ `${props.row.email}` }}
</b-table-column>
<b-table-column v-slot="props" field="role" label="Role">
{{ `${props.row.roleDetail.short}` }}
<b-table-column v-slot="props" field="role" :label="$t('role')">
{{ `${$t('roles.' + props.row.roleDetail.key)}` }}
</b-table-column>
<b-table-column v-slot="props" field="createdAt" label="Created" sortable date>
<b-table-column v-slot="props" field="createdAt" :label="$t('created')" sortable date>
{{ formatDate(props.row.createdAt) }}
</b-table-column>
<b-table-column v-slot="props">
<b-button type="is-success" v-if="props.row.isDisabled && props.row.roleDetail.long === 'USER'" @click="changeDisabledStatus(props.row.id, false)">Enable</b-button>
<b-button type="is-danger" v-if="!props.row.isDisabled && props.row.roleDetail.long === 'USER'" @click="changeDisabledStatus(props.row.id, true)">Disable</b-button>
<b-button v-if="props.row.isDisabled && props.row.roleDetail.key === 'USER'" type="is-success" @click="changeDisabledStatus(props.row.id, false)">{{ $t('enable') }}</b-button>
<b-button v-if="!props.row.isDisabled && props.row.roleDetail.key === 'USER'" type="is-danger" @click="changeDisabledStatus(props.row.id, true)">{{ $t('disable') }}</b-button>
</b-table-column>
</b-table>
</div>

View File

@@ -4,7 +4,7 @@ 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 currencyFormatter from 'currency-formatter'
import store from '@state/store'
import ShareVehicle from '@components/shareVehicle.vue'
import MileageChart from '@components/mileageChart.vue'
@@ -40,14 +40,20 @@ export default {
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' },
{ label: this.$t('thisweek'), value: 'this_week' },
{ label: this.$t('thismonth'), value: 'this_month' },
{ label: this.$tc('pastxdays', 30), value: 'past_30_days' },
{ label: this.$tc('pastxmonths', 3), value: 'past_3_months' },
{ label: this.$t('thisyear'), value: 'this_year' },
{ label: this.$t('alltime'), value: 'all_time' },
],
dateRangeOption: 'past_30_days',
mileageOptions: [
{ label: 'L/100km', value: 'litre_100km' },
{ label: 'km/L', value: 'km_litre' },
{ label: 'mpg', value: 'mpg' },
],
mileageOption: 'litre_100km',
}
},
computed: {
@@ -61,32 +67,35 @@ export default {
return this.stats.map((x) => {
return [
{
label: 'Currency',
label: this.$t('currency'),
value: x.currency,
},
{
label: 'Total Expenditure',
label: this.$t('totalexpenses'),
value: this.formatCurrency(x.expenditureTotal, x.currency),
},
{
label: 'Fillup Costs',
label: this.$t('fillupcost'),
value: `${this.formatCurrency(x.expenditureFillups, x.currency)} (${x.countFillups})`,
},
{
label: 'Other Expenses',
label: this.$t('otherexpenses'),
value: `${this.formatCurrency(x.expenditureExpenses, x.currency)} (${x.countExpenses})`,
},
{
label: 'Avg Fillup Expense',
label: this.$t('avgfillupexpense'),
value: `${this.formatCurrency(x.avgFillupCost, x.currency)}`,
},
{
label: 'Avg Fillup Qty',
value: `${x.avgFuelQty} ${this.vehicle.fuelUnitDetail.short}`,
label: this.$t('avgfillupqty'),
value: `${x.avgFuelQty} ${this.$t('unit.short.' + this.vehicle.fuelUnitDetail.key)}`,
},
{
label: 'Avg Fuel Cost',
value: `${this.formatCurrency(x.avgFuelPrice, x.currency)} per ${this.vehicle.fuelUnitDetail.short}`,
label: this.$t('avgfuelcost'),
value: this.$t('per', {
0: this.formatCurrency(x.avgFuelPrice, x.currency),
1: this.$t('unit.short.' + this.vehicle.fuelUnitDetail.key),
}),
},
]
})
@@ -199,14 +208,21 @@ export default {
return
}
this.tryingToUpload = true
const formData = new FormData()
formData.append('file', this.file, this.file.name)
formData.append('title', this.title)
axios
.post(`/api/vehicles/${this.vehicle.id}/attachments`, formData)
// const config = { headers: { 'Content-Type': 'multipart/form-data; boundary=' + formData._boundary } }
fetch(`/api/vehicles/${this.vehicle.id}/attachments`, {
method: 'POST',
body: formData,
headers: {
Authorization: this.currentUser.token,
},
})
.then((data) => {
this.$buefy.toast.open({
message: 'Quick Entry Created Successfully',
message: 'File uploaded Successfully',
type: 'is-success',
duration: 3000,
})
@@ -233,7 +249,7 @@ export default {
if (!currencyCode) {
currencyCode = this.me.currency
}
return currencyFormtter.format(number, { code: currencyCode })
return currencyFormatter.format(number, { code: currencyCode })
},
columnTdAttrs(row, column) {
return null
@@ -293,18 +309,18 @@ export default {
<template>
<Layout>
<div class="columns box">
<div class="column is-two-thirds" :class="isMobile ? 'has-text-centered' : ''">
<div class="column is-one-half" :class="isMobile ? 'has-text-centered' : ''">
<p class="title">{{ vehicle.nickname }} - {{ vehicle.registration }}</p>
<p class="subtitle">
{{ [vehicle.make, vehicle.model, vehicle.fuelTypeDetail.long].join(' | ') }}
{{ [vehicle.make, vehicle.model, $t('fuel.' + vehicle.fuelTypeDetail.key)].join(' | ') }}
<template v-if="users.length > 1">
| Shared with :
| {{ $t('sharedwith') }} :
{{
users
.map((x) => {
if (x.userId === me.id) {
return 'You'
return $t('you')
} else {
return x.name
}
@@ -314,25 +330,24 @@ export default {
</template>
</p>
</div>
<div class="column is-one-third buttons has-text-centered">
<b-button type="is-primary" tag="router-link" :to="`/vehicles/${vehicle.id}/fillup`">Add Fillup</b-button>
<b-button type="is-primary" tag="router-link" :to="`/vehicles/${vehicle.id}/expense`">Add Expense</b-button>
<div :class="(!isMobile ? 'has-text-right ' : '') + 'column is-one-half buttons'">
<b-button type="is-primary" tag="router-link" :to="`/vehicles/${vehicle.id}/fillup`">{{ $t('addfillup') }}</b-button>
<b-button type="is-primary" tag="router-link" :to="`/vehicles/${vehicle.id}/expense`">{{ $t('addexpense') }}</b-button>
<b-button
v-if="vehicle.isOwner"
tag="router-link"
title="Edit Vehicle"
:title="$t('editvehicle')"
:to="{
name: 'vehicle-edit',
props: { vehicle: vehicle },
params: { id: vehicle.id },
}"
>
<b-icon pack="fas" icon="edit" type="is-info"> </b-icon
></b-button>
<b-button v-if="vehicle.isOwner" title="Share vehicle" @click="showShareVehicleModal">
><b-icon pack="fas" icon="edit" type="is-info"> </b-icon>
</b-button>
<b-button v-if="vehicle.isOwner" :title="$t('sharevehicle')" @click="showShareVehicleModal">
<b-icon pack="fas" icon="user-friends" type="is-info"> </b-icon
></b-button>
<b-button v-if="vehicle.isOwner" title="Delete Vehicle" @click="deleteVehicle">
<b-button v-if="vehicle.isOwner" :title="$t('deletevehicle')" @click="deleteVehicle">
<b-icon pack="fas" icon="trash" type="is-danger"> </b-icon
></b-button>
</div>
@@ -346,45 +361,45 @@ export default {
</div>
</div>
<div class="box">
<h1 class="title is-4">Past Fillups</h1>
<h1 class="title is-4">{{ $t('pastfillups') }}</h1>
<b-table :data="fillups" hoverable mobile-cards :detailed="isMobile" detail-key="id" paginated per-page="10">
<b-table-column v-slot="props" field="date" label="Date" :td-attrs="columnTdAttrs" sortable date>
<b-table-column v-slot="props" field="date" :label="$t('date')" :td-attrs="columnTdAttrs" sortable date>
{{ formatDate(props.row.date) }}
</b-table-column>
<b-table-column v-slot="props" field="fuelSubType" label="Fuel Sub Type" :td-attrs="columnTdAttrs">
<b-table-column v-slot="props" field="fuelSubType" :label="$t('fuelsubtype')" :td-attrs="columnTdAttrs">
{{ props.row.fuelSubType }}
</b-table-column>
<b-table-column v-slot="props" field="fuelQuantity" label="Qty." :td-attrs="hiddenMobile" numeric>
{{ `${props.row.fuelQuantity} ${props.row.fuelUnitDetail.short}` }}
<b-table-column v-slot="props" field="fuelQuantity" :label="$t('quantity')" :td-attrs="hiddenMobile" numeric>
{{ `${props.row.fuelQuantity} ${$t('unit.short.' + props.row.fuelUnitDetail.key)}` }}
</b-table-column>
<b-table-column
v-slot="props"
field="perUnitPrice"
:label="'Price per ' + vehicle.fuelUnitDetail.short"
:label="$t('per', { '0': $t('price'), '1': $t('unit.short.' + vehicle.fuelUnitDetail.key) })"
:td-attrs="hiddenMobile"
numeric
sortable
>
{{ `${formatCurrency(props.row.perUnitPrice, props.row.currency)}` }}
</b-table-column>
<b-table-column v-if="isMobile" v-slot="props" field="totalAmount" label="Total" :td-attrs="hiddenDesktop" sortable numeric>
{{ `${me.currency} ${props.row.totalAmount}` }} ({{ `${props.row.fuelQuantity} ${props.row.fuelUnitDetail.short}` }} @
<b-table-column v-if="isMobile" v-slot="props" field="totalAmount" :label="$t('total')" :td-attrs="hiddenDesktop" sortable numeric>
{{ `${me.currency} ${props.row.totalAmount}` }} ({{ `${props.row.fuelQuantity} ${$t('unit.short.' + props.row.fuelUnitDetail.key)}` }} @
{{ `${me.currency} ${props.row.perUnitPrice}` }})
</b-table-column>
<b-table-column v-if="!isMobile" v-slot="props" field="totalAmount" label="Total" :td-attrs="hiddenMobile" sortable numeric>
<b-table-column v-if="!isMobile" v-slot="props" field="totalAmount" :label="$t('total')" :td-attrs="hiddenMobile" sortable numeric>
{{ `${formatCurrency(props.row.totalAmount, props.row.currency)}` }}
</b-table-column>
<b-table-column v-slot="props" width="20" field="isTankFull" label="Tank Full" :td-attrs="hiddenMobile">
<b-table-column v-slot="props" width="20" field="isTankFull" :label="$t('fulltank')" :td-attrs="hiddenMobile">
<b-icon pack="fas" :icon="props.row.isTankFull ? 'check' : 'times'" type="is-info"> </b-icon>
</b-table-column>
<b-table-column v-slot="props" field="odoReading" label="Odometer Reading" :td-attrs="hiddenMobile" numeric>
{{ `${props.row.odoReading} ${me.distanceUnitDetail.short}` }}
<b-table-column v-slot="props" field="odoReading" :label="$t('odometer')" :td-attrs="hiddenMobile" numeric>
{{ `${props.row.odoReading} ${$t('unit.short.' + me.distanceUnitDetail.key)}` }}
</b-table-column>
<b-table-column v-slot="props" field="fillingStation" label="Fillup Station" :td-attrs="hiddenMobile">
<b-table-column v-slot="props" field="fillingStation" :label="$t('gasstation')" :td-attrs="hiddenMobile">
{{ `${props.row.fillingStation}` }}
</b-table-column>
<b-table-column v-slot="props" field="userId" label="By" :td-attrs="hiddenMobile">
<b-table-column v-slot="props" field="userId" :label="$t('by')" :td-attrs="hiddenMobile">
{{ `${props.row.user.name}` }}
</b-table-column>
<b-table-column v-slot="props">
@@ -399,11 +414,14 @@ export default {
>
<b-icon pack="fas" icon="edit" type="is-info"> </b-icon
></b-button>
<b-button type="is-ghost" title="Delete this fillup" @click="deleteFillup(props.row.id)">
<b-button
type="is-ghost"
:title="$t('deletefillup')"
@click="deleteFillup(props.row.id)">
<b-icon pack="fas" icon="trash" type="is-danger"> </b-icon
></b-button>
</b-table-column>
<template v-slot:empty> No Fillups so far</template>
<template v-slot:empty> {{ $t('nofillups') }}</template>
<template v-slot:detail="props">
<p>{{ props.row.id }}</p>
</template>
@@ -411,25 +429,25 @@ export default {
</div>
<br />
<div class="box">
<h1 class="title is-4">Past Expenses</h1>
<h1 class="title is-4">{{ $t('expenses') }}</h1>
<b-table :data="expenses" hoverable mobile-cards paginated per-page="10">
<b-table-column v-slot="props" field="date" label="Date" :td-attrs="columnTdAttrs" date>
<b-table-column v-slot="props" field="date" :label="$t('date')" :td-attrs="columnTdAttrs" date>
{{ formatDate(props.row.date) }}
</b-table-column>
<b-table-column v-slot="props" field="expenseType" label="Expense Type">
<b-table-column v-slot="props" field="expenseType" :label="$t('expensetype')">
{{ `${props.row.expenseType}` }}
</b-table-column>
<b-table-column v-slot="props" field="amount" label="Total" :td-attrs="hiddenMobile" sortable numeric>
<b-table-column v-slot="props" field="amount" :label="$t('total')" :td-attrs="hiddenMobile" sortable numeric>
{{ `${formatCurrency(props.row.amount, props.row.currency)}` }}
</b-table-column>
<b-table-column v-slot="props" field="odoReading" label="Odometer Reading" :td-attrs="columnTdAttrs" numeric>
{{ `${props.row.odoReading} ${me.distanceUnitDetail.short}` }}
<b-table-column v-slot="props" field="odoReading" :label="$t('odometer')" :td-attrs="columnTdAttrs" numeric>
{{ `${props.row.odoReading} ${$t('unit.short.' + me.distanceUnitDetail.key)}` }}
</b-table-column>
<b-table-column v-slot="props" field="userId" label="By" :td-attrs="columnTdAttrs">
<b-table-column v-slot="props" field="userId" :label="$t('by')" :td-attrs="columnTdAttrs">
{{ `${props.row.user.name}` }}
</b-table-column>
<b-table-column v-slot="props">
@@ -444,20 +462,22 @@ export default {
>
<b-icon pack="fas" icon="edit" type="is-info"> </b-icon
></b-button>
<b-button type="is-ghost" title="Delete this expense" @click="deleteExpense(props.row.id)">
<b-button type="is-ghost" :title="$t('deleteexpense')" @click="deleteExpense(props.row.id)">
<b-icon pack="fas" icon="trash" type="is-danger"> </b-icon
></b-button>
</b-table-column>
<template v-slot:empty> No Expenses so far</template>
<template v-slot:empty> {{ $t('noexpenses') }}</template>
</b-table>
</div>
<br />
<div class="box">
<div class="columns">
<div class="column is-three-quarters"> <h1 class="title is-4">Attachments</h1></div>
<div class="column is-three-quarters">
<h1 class="title is-4">{{ $t('attachments') }}</h1></div
>
<div class="column buttons">
<b-button type="is-primary" @click="showAttachmentForm = true">
Add Attachment
{{ $t('addattachment') }}
</b-button>
</div>
</div>
@@ -471,7 +491,7 @@ export default {
<b-upload v-model="file" class="file-label" required>
<span class="file-cta">
<b-icon class="file-icon" icon="upload"></b-icon>
<span class="file-label">Choose File</span>
<span class="file-label">{{ $t('choosefile') }}</span>
</span>
<span v-if="file" class="file-name" :class="isMobile ? 'file-name-mobile' : 'file-name-desktop'">
{{ file.name }}
@@ -479,18 +499,18 @@ export default {
</b-upload>
</b-field>
<b-field>
<b-input v-model="title" required placeholder="Label for this file"></b-input>
<b-input v-model="title" required :placeholder="$t('labelforfile')"></b-input>
</b-field>
<b-field class="buttons">
<b-button tag="input" native-type="submit" :disabled="tryingToUpload" type="is-primary" label="Upload File" value="Upload File">
<b-button tag="button" native-type="submit" :disabled="tryingToUpload" type="is-primary" label="Upload File" value="Upload File">
</b-button>
<b-button
tag="input"
tag="button"
native-type="submit"
:disabled="tryingToUpload"
type="is-danger"
label="Upload File"
label="Cancel"
value="Cancel"
@click="showAttachmentForm = false"
>
@@ -503,33 +523,46 @@ export default {
</div>
<b-table :data="attachments" hoverable mobile-cards>
<b-table-column v-slot="props" field="title" label="Title" :td-attrs="columnTdAttrs">
<b-table-column v-slot="props" field="title" :label="$t('title')" :td-attrs="columnTdAttrs">
{{ `${props.row.title}` }}
</b-table-column>
<b-table-column v-slot="props" field="originalName" label="Name" :td-attrs="columnTdAttrs">
<b-table-column v-slot="props" field="originalName" :label="$t('name')" :td-attrs="columnTdAttrs">
{{ `${props.row.originalName}` }}
</b-table-column>
<b-table-column v-slot="props" field="id" label="Download" :td-attrs="columnTdAttrs">
<b-table-column v-slot="props" field="id" :label="$t('download')" :td-attrs="columnTdAttrs">
<b-button tag="a" :href="`/api/attachments/${props.row.id}/file?access_token=${currentUser.token}`" :download="props.row.originalName">
<b-icon type="is-primary" icon="download"></b-icon>
</b-button>
</b-table-column>
<template v-slot:empty> No Attachments so far</template>
<template v-slot:empty> {{ $t('noattachments') }}</template>
</b-table>
</div>
<div class="box">
<div class="columns">
<div class="column" :class="isMobile ? 'has-text-centered' : ''"> <h1 class="title">Stats</h1></div>
<div class="column">
<b-select v-model="dateRangeOption" class="is-pulled-right is-medium">
<option v-for="option in dateRangeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</b-select></div
<div class="column" :class="isMobile ? 'has-text-centered' : ''">
<h1 class="title">{{ $t('statistics') }}</h1></div
>
<div class="column">
<div class="columns is-pulled-right is-medium">
<div class="column">
<b-select v-model="mileageOption">
<option v-for="option in mileageOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</b-select>
</div>
<div class="column">
<b-select v-model="dateRangeOption">
<option v-for="option in dateRangeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</b-select>
</div>
</div>
</div>
</div>
<MileageChart :vehicle="vehicle" :since="getStartDate()" :user="me" :height="300" />
<MileageChart :vehicle="vehicle" :since="getStartDate()" :user="me" :height="300" :mileage-option="mileageOption" />
</div>
</Layout>
</template>

14272
ui/yarn.lock

File diff suppressed because it is too large Load Diff