Compare commits

...
Sign in to create a new pull request.

32 commits

Author SHA1 Message Date
84da9c2314
fix ui 2025-05-28 17:01:20 +02:00
34211970ae
improve form errors 2025-04-27 14:07:11 +02:00
49a39f436c
style: formatting 2025-04-25 14:45:54 +02:00
96c3c822cd
fix importation of transaction (type of request)
add importer for Banque Populaire transactions
2025-04-09 08:51:26 +02:00
7de0299901
Merge branch 'feature/js-models' into develop 2025-03-31 18:30:36 +02:00
5d55144160
remove @vitejs/plugin-vue 2025-03-31 16:51:02 +02:00
162687cdf0
add models 2025-03-31 16:44:01 +02:00
c1ce115590
feat: add dynamtic title 2025-03-30 14:17:49 +02:00
1b03ecd905
feat(saving_account): add columns 2025-03-30 14:12:42 +02:00
bb14414af1
fix(saving_account): cast string to float (amounts) 2025-03-30 14:12:11 +02:00
fa21f32acd
feat(pager): hide pager when pages < 2 2025-03-30 14:11:27 +02:00
9eba55f530
feat(transaction): add rule from transaction 2025-03-28 11:36:27 +01:00
a0f5efa68e
fix stuff 2025-03-27 17:13:43 +01:00
2ebb476ad1
feat(ui): transaction table 2025-03-27 08:40:18 +01:00
44ee6b8a07
add route for transaction 2025-03-26 17:33:08 +01:00
052da0a0a1
fix: fix eslint warnings/errors 2025-03-26 14:18:44 +01:00
be4ebc8f27
apply linter 2025-03-26 12:35:19 +01:00
3ca85251f4
apply linter 2025-03-26 12:34:05 +01:00
f9fb6e5786
config(prettierrc): set printWidth 2025-03-26 12:33:56 +01:00
4918abd5d6
fix: fix eslint warnings/errors 2025-03-26 11:40:19 +01:00
3a90060d83
fix: fix eslint warnings/errors 2025-03-26 11:40:14 +01:00
e773ab0e97
fix(datalist): typo on doSort 2025-03-26 10:28:16 +01:00
68e32fbffa
fix: remove unused vars 2025-03-26 09:23:27 +01:00
40c1c12fcc
apply linter 2025-03-26 09:03:58 +01:00
84e201ba41
Merge branch 'feature/refactoring' into develop 2025-03-25 23:02:43 +01:00
aa4ee39f01
apply linter 2025-03-25 23:02:31 +01:00
58b78dd335
refactoring: add sub routes and slit in several components 2025-03-25 22:54:34 +01:00
262b9cab17
change default font to ubuntu 2025-03-23 18:18:00 +01:00
a6f3ed315b
update ui 2025-03-23 17:44:30 +01:00
b5cd785b4e
fix sonarqube review 2025-03-16 23:22:33 +01:00
e535deb8b5
fix some of sonarqube review 2025-03-16 23:06:03 +01:00
ec45abd911
feat(security): set secure auth cookie 2025-03-16 22:36:38 +01:00
80 changed files with 5590 additions and 5495 deletions

View file

@ -1,7 +1,8 @@
{
"bracketSpacing": false,
"bracketSpacing": true,
"bracketSameLine": false,
"semi": false,
"singleQuote": true,
"singleAttributePerLine": true
"singleAttributePerLine": true,
"printWidth": 160
}

View file

@ -1,4 +1,4 @@
FROM alpine
FROM alpine:3
COPY ./budget-go /usr/bin/
COPY ./budget-go-client /usr/bin/

View file

@ -18,9 +18,12 @@ type Controller struct {
func New(e *echo.Echo) *Controller {
c := Controller{}
e.GET("/login", c.LoginGet)
e.POST("/login", c.LoginPost)
e.GET("/logout", c.LogoutGet)
loginRoute := "/login"
logoutRoute := "/logout"
e.GET(loginRoute, c.LoginGet)
e.POST(loginRoute, c.LoginPost)
e.GET(logoutRoute, c.LogoutGet)
return &c
}
@ -50,6 +53,7 @@ func (ctrl *Controller) LoginPost(c echo.Context) error {
Path: "/",
MaxAge: 3600 * 24 * 2,
HttpOnly: true,
Secure: true,
}
sess.Values["user"] = user.ID
sess.Save(c.Request(), c.Response())

View file

@ -3,6 +3,7 @@ package bank_account
import (
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/backend/controller/crud"
"gitnet.fr/deblan/budget/backend/message"
"gitnet.fr/deblan/budget/database/model"
"gorm.io/gorm"
)
@ -29,18 +30,21 @@ func New(e *echo.Echo) *Controller {
crud: crud.New(),
}
e.GET("/api/bank_account", c.List)
e.POST("/api/bank_account", c.Create)
e.GET("/api/bank_account/:id", c.Show)
e.POST("/api/bank_account/:id", c.Update)
e.DELETE("/api/bank_account/:id", c.Delete)
listRoute := "/api/bank_account"
itemRoute := "/api/bank_account/:id"
e.GET(listRoute, c.List)
e.POST(listRoute, c.Create)
e.GET(itemRoute, c.Show)
e.POST(itemRoute, c.Update)
e.DELETE(itemRoute, c.Delete)
return &c
}
func (ctrl *Controller) List(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).List(c)
@ -48,7 +52,7 @@ func (ctrl *Controller) List(c echo.Context) error {
func (ctrl *Controller) Show(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).Show(c)
@ -56,7 +60,7 @@ func (ctrl *Controller) Show(c echo.Context) error {
func (ctrl *Controller) Delete(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).Delete(c)
@ -64,7 +68,7 @@ func (ctrl *Controller) Delete(c echo.Context) error {
func (ctrl *Controller) Create(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
type body struct {
@ -84,7 +88,7 @@ func (ctrl *Controller) Create(c echo.Context) error {
func (ctrl *Controller) Update(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
type body struct {

View file

@ -3,6 +3,7 @@ package category
import (
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/backend/controller/crud"
"gitnet.fr/deblan/budget/backend/message"
"gitnet.fr/deblan/budget/database/manager"
"gitnet.fr/deblan/budget/database/model"
"gorm.io/gorm"
@ -36,18 +37,21 @@ func New(e *echo.Echo) *Controller {
crud: crud.New(),
}
e.GET("/api/category", c.List)
e.POST("/api/category", c.Create)
e.GET("/api/category/:id", c.Show)
e.POST("/api/category/:id", c.Update)
e.DELETE("/api/category/:id", c.Delete)
listRoute := "/api/category"
itemRoute := "/api/category/:id"
e.GET(listRoute, c.List)
e.POST(listRoute, c.Create)
e.GET(itemRoute, c.Show)
e.POST(itemRoute, c.Update)
e.DELETE(itemRoute, c.Delete)
return &c
}
func (ctrl *Controller) List(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).List(c)
@ -55,7 +59,7 @@ func (ctrl *Controller) List(c echo.Context) error {
func (ctrl *Controller) Show(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).Show(c)
@ -63,7 +67,7 @@ func (ctrl *Controller) Show(c echo.Context) error {
func (ctrl *Controller) Delete(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).Delete(c)
@ -71,7 +75,7 @@ func (ctrl *Controller) Delete(c echo.Context) error {
func (ctrl *Controller) Create(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
type body struct {
@ -94,7 +98,7 @@ func (ctrl *Controller) Create(c echo.Context) error {
func (ctrl *Controller) Update(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
type body struct {

View file

@ -12,6 +12,7 @@ import (
"time"
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/backend/message"
"gitnet.fr/deblan/budget/backend/view"
tpl "gitnet.fr/deblan/budget/backend/view/template/collabora"
"gitnet.fr/deblan/budget/config"
@ -105,7 +106,7 @@ func (ctrl *Controller) Info(c echo.Context) error {
}
if file == "" {
return c.JSON(404, "File not found")
return c.JSON(404, message.FileNotFound)
}
fi, _ := os.Stat(file)
@ -141,7 +142,7 @@ func (ctrl *Controller) WopiContentGet(c echo.Context) error {
}
if file == "" {
return c.JSON(404, "File not found")
return c.JSON(404, message.FileNotFound)
}
content, _ := os.ReadFile(file)
@ -165,7 +166,7 @@ func (ctrl *Controller) WopiContentPost(c echo.Context) error {
}
if file == "" {
return c.JSON(404, "File not found")
return c.JSON(404, message.FileNotFound)
}
data, _ := ioutil.ReadAll(c.Request().Body)

View file

@ -7,26 +7,45 @@ import (
"strings"
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/backend/message"
"gitnet.fr/deblan/budget/database/manager"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/go-playground/locales/fr"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
fr_translations "github.com/go-playground/validator/v10/translations/fr"
)
type UpdateCallback func(*gorm.DB, interface{}, interface{}) (interface{}, error)
type CreateCallback func(*gorm.DB, interface{}) (interface{}, error)
type Error struct {
Code int `json:"code"`
Message string `json:"message"`
Code int `json:"code"`
Message string `json:"message"`
Errors validator.ValidationErrorsTranslations `json:"errors"`
}
type Controller struct {
Config Configuration
Config Configuration
Validator *validator.Validate
Trans ut.Translator
}
func New() *Controller {
c := Controller{}
fr := fr.New()
uni := ut.New(fr, fr)
trans, _ := uni.GetTranslator("fr")
validate := validator.New()
fr_translations.RegisterDefaultTranslations(validate, trans)
c.Validator = validate
c.Trans = trans
return &c
}
@ -35,6 +54,10 @@ func (ctrl *Controller) With(config Configuration) *Controller {
return ctrl
}
var (
IdEqual = "id = ?"
)
func (ctrl *Controller) List(c echo.Context) error {
db := manager.Get().Db
db = db.Model(ctrl.Config.Model)
@ -92,7 +115,7 @@ func (ctrl *Controller) Show(c echo.Context) error {
}
var count int64
db.Model(ctrl.Config.Model).Where("id = ?", value).Count(&count)
db.Model(ctrl.Config.Model).Where(IdEqual, value).Count(&count)
if count == 0 {
return c.JSON(404, Error{
@ -102,7 +125,7 @@ func (ctrl *Controller) Show(c echo.Context) error {
}
item := ctrl.Config.CreateModel()
db.Model(ctrl.Config.Model).Where("id = ?", value)
db.Model(ctrl.Config.Model).Where(IdEqual, value)
if ctrl.Config.ItemQuery != nil {
ctrl.Config.ItemQuery(db)
@ -125,17 +148,17 @@ func (ctrl *Controller) Delete(c echo.Context) error {
}
var count int64
db.Model(ctrl.Config.Model).Where("id = ?", value).Count(&count)
db.Model(ctrl.Config.Model).Where(IdEqual, value).Count(&count)
if count == 0 {
return c.JSON(404, Error{
Code: 404,
Message: "Not found",
Message: message.NotFound,
})
}
item := ctrl.Config.CreateModel()
db.Model(ctrl.Config.Model).Where("id = ?", value).Delete(&item)
db.Model(ctrl.Config.Model).Where(IdEqual, value).Delete(&item)
return c.JSON(200, nil)
}
@ -146,14 +169,17 @@ func (ctrl *Controller) Create(c echo.Context, body interface{}, createCallback
if err := c.Bind(body); err != nil {
return c.JSON(400, Error{
Code: 400,
Message: "Bad request",
Message: message.BadRequest,
})
}
if err := c.Validate(body); err != nil {
if err := ctrl.Validator.Struct(body); err != nil {
errs := err.(validator.ValidationErrors)
return c.JSON(400, Error{
Code: 400,
Message: err.Error(),
Message: message.InvalidForm,
Errors: errs.Translate(ctrl.Trans),
})
}
@ -181,12 +207,12 @@ func (ctrl *Controller) Update(c echo.Context, body interface{}, updateCallback
}
var count int64
db.Model(ctrl.Config.Model).Where("id = ?", value).Count(&count)
db.Model(ctrl.Config.Model).Where(IdEqual, value).Count(&count)
if count == 0 {
return c.JSON(404, Error{
Code: 404,
Message: "Not found",
Message: message.NotFound,
})
}
@ -206,10 +232,13 @@ func (ctrl *Controller) Update(c echo.Context, body interface{}, updateCallback
})
}
if err := c.Validate(body); err != nil {
if err := ctrl.Validator.Struct(body); err != nil {
errs := err.(validator.ValidationErrors)
return c.JSON(400, Error{
Code: 400,
Message: err.Error(),
Message: message.InvalidForm,
Errors: errs.Translate(ctrl.Trans),
})
}

View file

@ -9,6 +9,7 @@ import (
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/backend/controller/crud"
"gitnet.fr/deblan/budget/backend/message"
"gitnet.fr/deblan/budget/config"
"gitnet.fr/deblan/budget/database/model"
f "gitnet.fr/deblan/budget/file"
@ -31,7 +32,7 @@ func New(e *echo.Echo) *Controller {
func (ctrl *Controller) List(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
tree := f.GetTree("", config.Get().File.Path)
@ -41,7 +42,7 @@ func (ctrl *Controller) List(c echo.Context) error {
func (ctrl *Controller) CreateFile(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
file, err := c.FormFile("file")
@ -77,7 +78,7 @@ func (ctrl *Controller) CreateFile(c echo.Context) error {
func (ctrl *Controller) CreateDirectory(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
type body struct {
@ -126,7 +127,7 @@ func (ctrl *Controller) CreateDirectory(c echo.Context) error {
func (ctrl *Controller) Download(c echo.Context) error {
if nil == model.LoadUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
var file string
@ -143,7 +144,7 @@ func (ctrl *Controller) Download(c echo.Context) error {
}
if file == "" {
return c.JSON(404, "File not found")
return c.JSON(404, message.FileNotFound)
}
content, _ := os.ReadFile(file)
@ -153,7 +154,7 @@ func (ctrl *Controller) Download(c echo.Context) error {
func (ctrl *Controller) Delete(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
var file string
@ -177,7 +178,7 @@ func (ctrl *Controller) Delete(c echo.Context) error {
}
if file == "" {
return c.JSON(404, "File not found")
return c.JSON(404, message.FileNotFound)
}
os.RemoveAll(file)

View file

@ -3,6 +3,7 @@ package saving_account
import (
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/backend/controller/crud"
"gitnet.fr/deblan/budget/backend/message"
"gitnet.fr/deblan/budget/database/model"
"gorm.io/gorm"
)
@ -29,18 +30,21 @@ func New(e *echo.Echo) *Controller {
crud: crud.New(),
}
e.GET("/api/saving_account", c.List)
e.POST("/api/saving_account", c.Create)
e.GET("/api/saving_account/:id", c.Show)
e.POST("/api/saving_account/:id", c.Update)
e.DELETE("/api/saving_account/:id", c.Delete)
listRoute := "/api/saving_account"
itemRoute := "/api/saving_account/:id"
e.GET(listRoute, c.List)
e.POST(listRoute, c.Create)
e.GET(itemRoute, c.Show)
e.POST(itemRoute, c.Update)
e.DELETE(itemRoute, c.Delete)
return &c
}
func (ctrl *Controller) List(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).List(c)
@ -48,7 +52,7 @@ func (ctrl *Controller) List(c echo.Context) error {
func (ctrl *Controller) Show(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).Show(c)
@ -56,7 +60,7 @@ func (ctrl *Controller) Show(c echo.Context) error {
func (ctrl *Controller) Delete(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).Delete(c)
@ -64,7 +68,7 @@ func (ctrl *Controller) Delete(c echo.Context) error {
func (ctrl *Controller) Create(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
type body struct {
@ -86,7 +90,7 @@ func (ctrl *Controller) Create(c echo.Context) error {
func (ctrl *Controller) Update(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
type body struct {

View file

@ -5,8 +5,10 @@ import (
"io"
"strconv"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/backend/controller/crud"
"gitnet.fr/deblan/budget/backend/message"
"gitnet.fr/deblan/budget/database/manager"
"gitnet.fr/deblan/budget/database/model"
"gorm.io/gorm"
@ -21,7 +23,7 @@ func (ctrl *Controller) Config() crud.Configuration {
Table: "transactions",
Model: model.Transaction{},
Models: []model.Transaction{},
ValidOrders: []string{"accounted_at", "short_label", "label", "reference", "information", "operation_type", "debit", "credit", "date", "category_id", "bank_account_id"},
ValidOrders: []string{"id", "accounted_at", "short_label", "label", "reference", "information", "operation_type", "debit", "credit", "date", "category_id", "bank_account_id"},
DefaultLimit: 20,
DefaultOrder: "date",
DefaultSort: "desc",
@ -50,7 +52,7 @@ func New(e *echo.Echo) *Controller {
func (ctrl *Controller) List(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).List(c)
@ -58,7 +60,7 @@ func (ctrl *Controller) List(c echo.Context) error {
func (ctrl *Controller) Show(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).Show(c)
@ -66,7 +68,7 @@ func (ctrl *Controller) Show(c echo.Context) error {
func (ctrl *Controller) UpdateCategories(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
datas := model.UpdateTransactionsCategories()
@ -76,7 +78,7 @@ func (ctrl *Controller) UpdateCategories(c echo.Context) error {
func (ctrl *Controller) Create(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
db := manager.Get().Db
@ -85,7 +87,10 @@ func (ctrl *Controller) Create(c echo.Context) error {
if err != nil {
return c.JSON(400, crud.Error{
Code: 400,
Message: err.Error(),
Message: message.InvalidForm,
Errors: validator.ValidationErrorsTranslations{
"BankAccount": message.BlankBankAccount,
},
})
}
@ -95,7 +100,10 @@ func (ctrl *Controller) Create(c echo.Context) error {
if count == 0 {
return c.JSON(400, crud.Error{
Code: 400,
Message: "Invalid bank account",
Message: message.InvalidForm,
Errors: validator.ValidationErrorsTranslations{
"BankAccount": message.UnknownBankAccount,
},
})
}
@ -103,7 +111,10 @@ func (ctrl *Controller) Create(c echo.Context) error {
if err != nil {
return c.JSON(400, crud.Error{
Code: 400,
Message: err.Error(),
Message: message.InvalidForm,
Errors: validator.ValidationErrorsTranslations{
"File": message.BlankFile,
},
})
}
@ -111,7 +122,10 @@ func (ctrl *Controller) Create(c echo.Context) error {
if err != nil {
return c.JSON(400, crud.Error{
Code: 400,
Message: err.Error(),
Message: message.InvalidForm,
Errors: validator.ValidationErrorsTranslations{
"File": message.UnproccessableFile,
},
})
}
@ -125,7 +139,10 @@ func (ctrl *Controller) Create(c echo.Context) error {
if err != nil {
return c.JSON(400, crud.Error{
Code: 400,
Message: err.Error(),
Message: message.InvalidForm,
Errors: validator.ValidationErrorsTranslations{
"File": message.InvalidFile,
},
})
}

View file

@ -3,6 +3,7 @@ package user
import (
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/backend/controller/crud"
"gitnet.fr/deblan/budget/backend/message"
"gitnet.fr/deblan/budget/database/model"
"gorm.io/gorm"
)
@ -28,18 +29,21 @@ func New(e *echo.Echo) *Controller {
crud: crud.New(),
}
e.GET("/api/user", c.List)
e.POST("/api/user", c.Create)
e.GET("/api/user/:id", c.Show)
e.POST("/api/user/:id", c.Update)
e.DELETE("/api/user/:id", c.Delete)
listRoute := "/api/user"
itemRoute := "/api/user/:id"
e.GET(listRoute, c.List)
e.POST(listRoute, c.Create)
e.GET(itemRoute, c.Show)
e.POST(itemRoute, c.Update)
e.DELETE(itemRoute, c.Delete)
return &c
}
func (ctrl *Controller) List(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).List(c)
@ -47,7 +51,7 @@ func (ctrl *Controller) List(c echo.Context) error {
func (ctrl *Controller) Show(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).Show(c)
@ -55,7 +59,7 @@ func (ctrl *Controller) Show(c echo.Context) error {
func (ctrl *Controller) Delete(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
return ctrl.crud.With(ctrl.Config()).Delete(c)
@ -63,7 +67,7 @@ func (ctrl *Controller) Delete(c echo.Context) error {
func (ctrl *Controller) Create(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
type body struct {
@ -84,7 +88,7 @@ func (ctrl *Controller) Create(c echo.Context) error {
func (ctrl *Controller) Update(c echo.Context) error {
if nil == model.LoadApiUser(c) {
return c.JSON(403, crud.Error{Code: 403, Message: "Login required"})
return c.JSON(403, crud.Error{Code: 403, Message: message.LoginRequired})
}
type body struct {

View file

@ -0,0 +1,14 @@
package message
var (
LoginRequired = "Login required"
FileNotFound = "File not found"
NotFound = "Not found"
BadRequest = "Bad request"
InvalidForm = "Le formulaire n'est pas valide"
BlankBankAccount = "Le compte bancaire n'est pas renseigné"
UnknownBankAccount = "Le compte bancaire n'existe pas"
BlankFile = "Le fichier n'est pas renseigné"
UnproccessableFile = "Le fichier ne peut être traité"
InvalidFile = "Le fichier n'est pas valide"
)

View file

@ -11,9 +11,9 @@ templ Page(hasError bool) {
<div class="container py-5 h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<div class="col-12 col-md-8 col-lg-6 col-xl-5">
<div class="card text-white" style="border-radius: 1rem;">
<div class="card" style="border-radius: 1rem;">
<div class="card-body py-2 px-5">
<div class="mb-md-5 mt-md-4">
<div class="mb-5 mt-4">
<div class="text-center pb-4">
<span class="fs-4">
<i class="fa-solid fa-coins"></i>

View file

@ -39,7 +39,7 @@ func Page(hasError bool) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<body><section class=\"vh-100\" id=\"login-form\"><div class=\"container py-5 h-100\"><div class=\"row d-flex justify-content-center align-items-center h-100\"><div class=\"col-12 col-md-8 col-lg-6 col-xl-5\"><div class=\"card text-white\" style=\"border-radius: 1rem;\"><div class=\"card-body py-2 px-5\"><div class=\"mb-md-5 mt-md-4\"><div class=\"text-center pb-4\"><span class=\"fs-4\"><i class=\"fa-solid fa-coins\"></i> <span id=\"app-name\">Budget</span></span></div><form action=\"/login\" method=\"POST\">")
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<body><section class=\"vh-100\" id=\"login-form\"><div class=\"container py-5 h-100\"><div class=\"row d-flex justify-content-center align-items-center h-100\"><div class=\"col-12 col-md-8 col-lg-6 col-xl-5\"><div class=\"card\" style=\"border-radius: 1rem;\"><div class=\"card-body py-2 px-5\"><div class=\"mb-5 mt-4\"><div class=\"text-center pb-4\"><span class=\"fs-4\"><i class=\"fa-solid fa-coins\"></i> <span id=\"app-name\">Budget</span></span></div><form action=\"/login\" method=\"POST\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View file

@ -1,19 +1,12 @@
package model
import (
"crypto/md5"
"encoding/csv"
"encoding/hex"
"errors"
"io/ioutil"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"gitnet.fr/deblan/budget/database/manager"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
)
type Transaction struct {
@ -64,8 +57,6 @@ func (t *Transaction) MatchRule(rule CategoryRule) (bool, int) {
if match {
counter += 8
}
} else {
match = match && true
}
if rule.BankCategory != nil {
@ -75,8 +66,6 @@ func (t *Transaction) MatchRule(rule CategoryRule) (bool, int) {
if match {
counter += 6
}
} else {
match = match && true
}
if rule.Amount != nil {
@ -85,8 +74,6 @@ func (t *Transaction) MatchRule(rule CategoryRule) (bool, int) {
if match {
counter += 4
}
} else {
match = match && true
}
if rule.DateFrom != nil {
@ -95,8 +82,6 @@ func (t *Transaction) MatchRule(rule CategoryRule) (bool, int) {
if match {
counter += 2
}
} else {
match = match && true
}
if rule.DateTo != nil {
@ -105,8 +90,10 @@ func (t *Transaction) MatchRule(rule CategoryRule) (bool, int) {
if match {
counter += 2
}
} else {
match = match && true
}
if match {
fmt.Printf("%+v\n", match)
}
return match, counter
@ -131,149 +118,15 @@ func (t *Transaction) MatchCategory(category Category) (bool, int) {
return match, maxCounter
}
func ImportCaisseEpargneTransactions(content string, bankAccountID int) ([]Transaction, error) {
lines := strings.Split(content, "\n")
db := manager.Get().Db
datas := []Transaction{}
for key, line := range lines {
if key == 0 {
continue
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Split(line, ";")
if len(fields) != 13 {
return datas, errors.New("Invalid format")
}
ref := fields[3]
if ref == "" {
hash := md5.New()
hash.Write([]byte(line))
hashInBytes := hash.Sum(nil)
ref = hex.EncodeToString(hashInBytes)
}
var count int64
db.Model(Transaction{}).Where(Transaction{Reference: ref}).Count(&count)
if count > 0 {
continue
}
transaction := Transaction{
ShortLabel: toUTF8(fields[1]),
Label: toUTF8(fields[2]),
Reference: ref,
Information: fields[4],
OperationType: fields[5],
AccountedAt: toDate(fields[0], "02/01/2006"),
BankCategory: fields[6],
BankSubCategory: fields[7],
Debit: -toFloat(fields[8]),
Credit: toFloat(fields[9]),
BankAccountID: bankAccountID,
Date: toDate(fields[10], "02/01/2006"),
}
db.Model(Transaction{}).Save(&transaction)
datas = append(datas, transaction)
}
return datas, nil
}
func ImportRevolutTransactions(content string, bankAccountID int) ([]Transaction, error) {
db := manager.Get().Db
datas := []Transaction{}
r := csv.NewReader(strings.NewReader(content))
records, err := r.ReadAll()
if err != nil {
return nil, err
}
for key, fields := range records {
if key == 0 {
continue
}
if len(fields) != 10 {
return datas, errors.New("Invalid format")
}
hash := md5.New()
hash.Write([]byte(strings.Join(fields, ",")))
hashInBytes := hash.Sum(nil)
ref := hex.EncodeToString(hashInBytes)
if fields[8] != "COMPLETED" {
continue
}
var count int64
db.Model(Transaction{}).Where(Transaction{Reference: ref}).Count(&count)
if count > 0 {
continue
}
amount := toFloat(fields[5])
var debit float64
var credit float64
if amount < 0 {
debit = -amount
credit = 0
} else {
debit = 0
credit = amount
}
date := toDate(fields[2], "2006-01-02 15:04:05")
transaction := Transaction{
ShortLabel: fields[4],
Label: fields[4],
Reference: ref,
Information: "",
OperationType: fields[0],
AccountedAt: date,
BankCategory: "",
BankSubCategory: "",
Debit: debit,
Credit: credit,
BankAccountID: bankAccountID,
Date: date,
}
db.Model(Transaction{}).Save(&transaction)
datas = append(datas, transaction)
}
return datas, nil
}
func ImportTransactions(content string, bankAccountID int, format string) ([]Transaction, error) {
var datas []Transaction
var err error
if format == "revolut" {
datas, err = ImportRevolutTransactions(content, bankAccountID)
} else {
} else if format == "banque_populaire" {
datas, err = ImportBanquePopulaireTransactions(content, bankAccountID)
} else if format == "caisse_epargne" {
datas, err = ImportCaisseEpargneTransactions(content, bankAccountID)
}
@ -311,23 +164,3 @@ func UpdateTransactionsCategories() []Transaction {
return datas
}
func toFloat(value string) float64 {
value = strings.ReplaceAll(value, ",", ".")
v, _ := strconv.ParseFloat(value, 64)
return v
}
func toDate(value, format string) time.Time {
v, _ := time.Parse(format, value)
return v
}
func toUTF8(input string) string {
reader := transform.NewReader(strings.NewReader(input), charmap.ISO8859_1.NewDecoder())
decoded, _ := ioutil.ReadAll(reader)
return string(decoded)
}

View file

@ -0,0 +1,81 @@
package model
import (
"crypto/md5"
"encoding/hex"
"errors"
"strings"
"gitnet.fr/deblan/budget/database/manager"
)
func ImportBanquePopulaireTransactions(content string, bankAccountID int) ([]Transaction, error) {
lines := strings.Split(content, "\n")
db := manager.Get().Db
datas := []Transaction{}
for key, line := range lines {
if key == 0 {
continue
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Split(line, ";")
if len(fields) != 8 {
return datas, errors.New("Invalid format")
}
hash := md5.New()
hash.Write([]byte(line))
hashInBytes := hash.Sum(nil)
ref := hex.EncodeToString(hashInBytes)
var count int64
db.Model(Transaction{}).Where(Transaction{
Reference: ref,
BankAccountID: bankAccountID,
}).Count(&count)
if count > 0 {
continue
}
amount := ToFloat(fields[6])
debit := 0.0
credit := 0.0
if amount > 0 {
credit = amount
} else {
debit = -amount
}
transaction := Transaction{
ShortLabel: "",
Label: ToUTF8(fields[3]),
Reference: ref,
Information: "",
OperationType: "",
AccountedAt: ToDate(fields[1], "02/01/2006"),
BankCategory: "",
BankSubCategory: "",
Debit: debit,
Credit: credit,
BankAccountID: bankAccountID,
Date: ToDate(fields[2], "02/01/2006"),
}
db.Model(Transaction{}).Save(&transaction)
datas = append(datas, transaction)
}
return datas, nil
}

View file

@ -0,0 +1,75 @@
package model
import (
"crypto/md5"
"encoding/hex"
"errors"
"strings"
"gitnet.fr/deblan/budget/database/manager"
)
func ImportCaisseEpargneTransactions(content string, bankAccountID int) ([]Transaction, error) {
lines := strings.Split(content, "\n")
db := manager.Get().Db
datas := []Transaction{}
for key, line := range lines {
if key == 0 {
continue
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Split(line, ";")
if len(fields) != 13 {
return datas, errors.New("Invalid format")
}
ref := fields[3]
if ref == "" {
hash := md5.New()
hash.Write([]byte(line))
hashInBytes := hash.Sum(nil)
ref = hex.EncodeToString(hashInBytes)
}
var count int64
db.Model(Transaction{}).Where(Transaction{
Reference: ref,
BankAccountID: bankAccountID,
}).Count(&count)
if count > 0 {
continue
}
transaction := Transaction{
ShortLabel: ToUTF8(fields[1]),
Label: ToUTF8(fields[2]),
Reference: ref,
Information: fields[4],
OperationType: fields[5],
AccountedAt: ToDate(fields[0], "02/01/2006"),
BankCategory: fields[6],
BankSubCategory: fields[7],
Debit: -ToFloat(fields[8]),
Credit: ToFloat(fields[9]),
BankAccountID: bankAccountID,
Date: ToDate(fields[10], "02/01/2006"),
}
db.Model(Transaction{}).Save(&transaction)
datas = append(datas, transaction)
}
return datas, nil
}

View file

@ -0,0 +1,31 @@
package model
import (
"io/ioutil"
"strconv"
"strings"
"time"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
)
func ToFloat(value string) float64 {
value = strings.ReplaceAll(value, ",", ".")
v, _ := strconv.ParseFloat(value, 64)
return v
}
func ToDate(value, format string) time.Time {
v, _ := time.Parse(format, value)
return v
}
func ToUTF8(input string) string {
reader := transform.NewReader(strings.NewReader(input), charmap.ISO8859_1.NewDecoder())
decoded, _ := ioutil.ReadAll(reader)
return string(decoded)
}

View file

@ -0,0 +1,88 @@
package model
import (
"crypto/md5"
"encoding/csv"
"encoding/hex"
"errors"
"strings"
"gitnet.fr/deblan/budget/database/manager"
)
func ImportRevolutTransactions(content string, bankAccountID int) ([]Transaction, error) {
db := manager.Get().Db
datas := []Transaction{}
r := csv.NewReader(strings.NewReader(content))
records, err := r.ReadAll()
if err != nil {
return nil, err
}
for key, fields := range records {
if key == 0 {
continue
}
if len(fields) != 10 {
return datas, errors.New("Invalid format")
}
hash := md5.New()
hash.Write([]byte(strings.Join(fields, ",")))
hashInBytes := hash.Sum(nil)
ref := hex.EncodeToString(hashInBytes)
if fields[8] != "COMPLETED" {
continue
}
var count int64
db.Model(Transaction{}).Where(Transaction{
Reference: ref,
BankAccountID: bankAccountID,
}).Count(&count)
if count > 0 {
continue
}
amount := ToFloat(fields[5])
var debit float64
var credit float64
if amount < 0 {
debit = -amount
credit = 0
} else {
debit = 0
credit = amount
}
date := ToDate(fields[2], "2006-01-02 15:04:05")
transaction := Transaction{
ShortLabel: fields[4],
Label: fields[4],
Reference: ref,
Information: "",
OperationType: fields[0],
AccountedAt: date,
BankCategory: "",
BankSubCategory: "",
Debit: debit,
Credit: credit,
BankAccountID: bankAccountID,
Date: date,
}
db.Model(Transaction{}).Save(&transaction)
datas = append(datas, transaction)
}
return datas, nil
}

View file

@ -1,6 +1,6 @@
<script setup>
import {RouterLink, RouterView} from 'vue-router'
import {BNavItem} from 'bootstrap-vue-next'
import { RouterLink, RouterView } from 'vue-router'
import { BNavItem } from 'bootstrap-vue-next'
</script>
<template>
@ -11,7 +11,7 @@ import {BNavItem} from 'bootstrap-vue-next'
>
<a
href="/"
class="mb-4 text-white text-decoration-none text-center"
class="mb-4 text-decoration-none text-center"
>
<span class="fs-4">
<i class="fa-solid fa-coins"></i>
@ -20,29 +20,21 @@ import {BNavItem} from 'bootstrap-vue-next'
</a>
<ul class="nav nav-pills flex-column mb-auto">
<RouterLink
v-for="url in [
'/',
'/transactions',
'/categories',
'/bank_accounts',
'/saving_accounts',
'/files',
'users',
]"
v-slot="{href, route, navigate, isActive, isExactActive}"
v-for="url in ['/', '/transactions', '/categories', '/bank_accounts', '/saving_accounts', '/files', '/users']"
v-slot="{ href, route, isActive }"
:key="url"
:to="url"
custom
>
<BNavItem
:href="href"
:active="isActive"
:link-class="{'text-white': !isActive}"
>
<i
class="me-2"
:class="route.meta.icon"
></i>
<span class="nav-item-label">{{ route.name }}</span>
<span class="nav-item-label">{{ route.meta.label }}</span>
</BNavItem>
</RouterLink>
</ul>
@ -50,12 +42,10 @@ import {BNavItem} from 'bootstrap-vue-next'
href="/logout"
class="px-3 p-lg-0 text-decoration-none"
>
<i class="text-white fa-solid fa-arrow-right-from-bracket"></i>
<span class="text-white ps-2 d-none d-lg-inline">Déconnexion</span>
<i class="fa-solid fa-arrow-right-from-bracket"></i>
<span class="ps-2 d-none d-lg-inline">Déconnexion</span>
</a>
</div>
<RouterView id="body" />
</main>
</template>
<style scoped></style>

View file

@ -1,4 +1,4 @@
import {isInRange} from '../lib/dateFilter'
import { isInRange } from '../lib/dateFilter'
const getDate = (value, precision) => {
const d = new Date(value)
@ -50,7 +50,7 @@ const compute = (transactions, precision, dateFrom, dateTo) => {
transactions.forEach((transaction) => {
let date = getDate(transaction.date, precision)
let begin = !Object.prototype.hasOwnProperty.call(indexes, date)
let begin = !Object.hasOwn(indexes, date)
if (begin) {
indexes[date] = labels.length
@ -79,4 +79,4 @@ const compute = (transactions, precision, dateFrom, dateTo) => {
return config
}
export {compute}
export { compute }

View file

@ -1,4 +1,4 @@
import {isInRange} from '../lib/dateFilter'
import { isInRange } from '../lib/dateFilter'
const getDate = (value) => {
const d = new Date(value)
@ -36,7 +36,7 @@ const compute = (transactions, dateFrom, dateTo, order) => {
let category = transaction.category ?? emptyCategory
if (!Object.prototype.hasOwnProperty.call(data, category.label)) {
if (!Object.hasOwn(data, category.label)) {
data[category.label] = {
category: category,
average: 0,
@ -52,7 +52,7 @@ const compute = (transactions, dateFrom, dateTo, order) => {
const date = getDate(transaction.date)
if (!Object.prototype.hasOwnProperty.call(data[category.label].months, date)) {
if (!Object.hasOwn(data[category.label].months, date)) {
data[category.label].months[date] = 0
}
@ -82,4 +82,4 @@ const compute = (transactions, dateFrom, dateTo, order) => {
return data
}
export {compute}
export { compute }

View file

@ -1,4 +1,4 @@
import {isInRange} from '../lib/dateFilter'
import { isInRange } from '../lib/dateFilter'
const getDate = (value) => {
const d = new Date(value)
@ -39,7 +39,7 @@ const compute = (transactions, dateFrom, dateTo) => {
const date = getDate(transaction.date)
if (!Object.prototype.hasOwnProperty.call(indexes, date)) {
if (!Object.hasOwn(indexes, date)) {
indexes[date] = labels.length
values[date] = 0
labels.push(date)
@ -68,4 +68,4 @@ const compute = (transactions, dateFrom, dateTo) => {
return config
}
export {compute}
export { compute }

View file

@ -1,4 +1,4 @@
import {isInRange} from '../lib/dateFilter'
import { isInRange } from '../lib/dateFilter'
const getDate = (value) => {
const d = new Date(value)
@ -13,13 +13,7 @@ const getDate = (value) => {
return `${month}/${year}`
}
const computeBar = (
transactions,
stacked,
dateFrom,
dateTo,
selectedCategories,
) => {
const computeBar = (transactions, stacked, dateFrom, dateTo, selectedCategories) => {
const indexes = {}
const labels = []
const bars = {}
@ -42,12 +36,12 @@ const computeBar = (
let date = getDate(transaction.date)
if (!Object.prototype.hasOwnProperty.call(indexes, date)) {
if (!Object.hasOwn(indexes, date)) {
indexes[date] = labels.length
labels.push(date)
}
if (!Object.prototype.hasOwnProperty.call(bars, category.label)) {
if (!Object.hasOwn(bars, category.label)) {
bars[category.label] = {
label: category.label,
data: [],
@ -78,8 +72,7 @@ const computeBar = (
let date = getDate(transaction.date)
bars[category.label].data[indexes[date]] +=
transaction.debit - transaction.credit
bars[category.label].data[indexes[date]] += transaction.debit - transaction.credit
})
labels.forEach((label, key) => {
@ -110,12 +103,7 @@ const computeBar = (
}
}
const computeDoughnut = (
transactions,
dateFrom,
dateTo,
selectedCategories,
) => {
const computeDoughnut = (transactions, dateFrom, dateTo, selectedCategories) => {
const indexes = {}
const labels = []
const data = []
@ -137,7 +125,7 @@ const computeDoughnut = (
return
}
if (!Object.prototype.hasOwnProperty.call(indexes, category.id)) {
if (!Object.hasOwn(indexes, category.id)) {
indexes[category.id] = labels.length
labels.push(category.label)
backgroundColor.push(category.color)
@ -164,4 +152,4 @@ const computeDoughnut = (
}
}
export {computeDoughnut, computeBar}
export { computeDoughnut, computeBar }

View file

@ -1,4 +1,4 @@
import {isInRange} from '../lib/dateFilter'
import { isInRange } from '../lib/dateFilter'
const getDate = (value) => {
const d = new Date(value)
@ -24,10 +24,7 @@ const compute = (transactions, cats, dateFrom, dateTo) => {
})
transactions.forEach((value) => {
if (
value.category === null ||
!Object.prototype.hasOwnProperty.call(categories, value.category.id)
) {
if (value.category === null || !Object.hasOwn(categories, value.category.id)) {
return
}
@ -37,16 +34,11 @@ const compute = (transactions, cats, dateFrom, dateTo) => {
const date = getDate(value.date)
if (!Object.prototype.hasOwnProperty.call(datas, date)) {
if (!Object.hasOwn(datas, date)) {
datas[date] = {}
}
if (
!Object.prototype.hasOwnProperty.call(
datas[date],
value.category.id.toString(),
)
) {
if (!Object.hasOwn(datas[date], value.category.id.toString())) {
datas[date][value.category.id.toString()] = 0
}
@ -73,4 +65,4 @@ const compute = (transactions, cats, dateFrom, dateTo) => {
return datas
}
export {compute}
export { compute }

View file

@ -27,4 +27,4 @@ const compute = (accounts) => {
}
}
export {compute}
export { compute }

View file

@ -18,9 +18,7 @@
</div>
<div class="col-12 col-md-7 mb-1">
<BFormInput
v-if="
item.type == 'string' && needValue(dataValue[key].comparator)
"
v-if="item.type == 'string' && needValue(dataValue[key].comparator)"
v-model="dataValue[key].value"
@change="changed"
/>
@ -33,18 +31,14 @@
/>
<BFormInput
v-if="
item.type == 'number' && needValue(dataValue[key].comparator)
"
v-if="item.type == 'number' && needValue(dataValue[key].comparator)"
v-model="dataValue[key].value"
type="number"
@change="changed"
/>
<BFormSelect
v-if="
item.type == 'select' && needValue(dataValue[key].comparator)
"
v-if="item.type == 'select' && needValue(dataValue[key].comparator)"
v-model="dataValue[key].value"
:options="item.options"
@change="changed"
@ -86,24 +80,34 @@
</template>
<script setup>
import {ref, onMounted, defineEmits} from 'vue'
import {BFormGroup, BFormInput, BFormSelect, BButton} from 'bootstrap-vue-next'
import { ref, onMounted } from 'vue'
import { BFormGroup, BFormInput, BFormSelect, BButton } from 'bootstrap-vue-next'
const emit = defineEmits(['update'])
const props = defineProps(['data', 'fields'])
const props = defineProps({
data: {
type: Array,
required: true,
},
fields: {
type: [Array, null],
required: true,
},
})
const dataValue = ref(props.data.value)
const choices = ref([])
const choice = ref(null)
const _ = {value: null, text: null}
const eq = {value: 'eq', text: '='}
const neq = {value: 'neq', text: '!='}
const like = {value: 'like', text: 'contient'}
const nlike = {value: 'nlike', text: 'ne content pas'}
const empty = {value: 'empty', text: 'est vide'}
const nEmpty = {value: 'nempty', text: 'est pas vide'}
const gt = {value: 'gt', text: '>='}
const lt = {value: 'lt', text: '<='}
const _ = { value: null, text: null }
const eq = { value: 'eq', text: '=' }
const neq = { value: 'neq', text: '!=' }
const like = { value: 'like', text: 'contient' }
const nlike = { value: 'nlike', text: 'ne content pas' }
const empty = { value: 'empty', text: 'est vide' }
const nEmpty = { value: 'nempty', text: 'est pas vide' }
const gt = { value: 'gt', text: '>=' }
const lt = { value: 'lt', text: '<=' }
const stringOptions = [_, eq, neq, like, nlike, empty, nEmpty]
const dateOptions = [_, eq, neq, gt, lt, empty, nEmpty]
@ -154,7 +158,7 @@ const doAdd = () => {
}
const doRemove = (key) => {
var items = []
let items = []
dataValue.value.forEach((v, k) => {
if (k !== key) {
@ -174,7 +178,7 @@ onMounted(() => {
dataValue.value = props.data
for (let field of props.fields) {
choices.value.push({value: field.key, text: field.label})
choices.value.push({ value: field.key, text: field.label })
}
})
</script>

View file

@ -1,5 +1,6 @@
<template>
<span>
<!-- eslint-disable vue/no-v-html -->
<span v-html="props.label"></span>
<span
@ -19,7 +20,28 @@
</template>
<script setup>
const props = defineProps(['currentOrder', 'currentSort', 'order', 'label'])
const props = defineProps({
currentOrder: {
type: [String, null],
required: true,
},
currentSort: {
type: [String, null],
required: true,
},
order: {
type: [String, null],
required: true,
},
sort: {
type: [String, null],
required: true,
},
label: {
type: [String, null],
required: true,
},
})
const isActive = () => {
return props.currentOrder === props.order

View file

@ -0,0 +1,33 @@
<template>
<div class="header d-block p-3 mb-3">
<div class="d-md-flex justify-content-between menu">
<h3>{{ title }}</h3>
<slot name="menu"></slot>
</div>
<div
v-if="$slots.bottom"
class="pt-3"
>
<slot name="bottom"></slot>
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: [String, null],
required: true,
},
})
</script>
<style>
.menu .btn {
margin-bottom: 4px;
}
.menu .btn:not(:last-child) {
margin-right: 4px;
}
</style>

View file

@ -1,6 +1,7 @@
<template>
<div class="d-flex justify-content-end gap-2 pe-3 pb-2">
<div class="d-flex justify-content-end gap-2">
<BPagination
v-if="pages > 1"
:model-value="page"
:per-page="limit"
:total-rows="limit * pages"
@ -12,7 +13,7 @@
:model-value="limit"
:options="limits()"
:required="true"
style="width: 70px"
style="width: 80px"
size="sm"
@change="updateLimit($event.target.value)"
/>
@ -20,10 +21,22 @@
</template>
<script setup>
import {defineProps, defineEmits} from 'vue'
import {BFormSelect, BPagination} from 'bootstrap-vue-next'
import { BFormSelect, BPagination } from 'bootstrap-vue-next'
defineProps(['page', 'pages', 'limit'])
defineProps({
page: {
type: Number,
required: true,
},
pages: {
type: Number,
required: true,
},
limit: {
type: Number,
required: true,
},
})
const emit = defineEmits(['update:page', 'update:limit', 'update'])
@ -43,7 +56,7 @@ const limits = () => {
for (let i of [10, 20, 50, 100, 0]) {
let label = i !== 0 ? i : 'Tout'
v.push({value: i, text: label})
v.push({ value: i, text: label })
}
return v

View file

@ -0,0 +1,90 @@
<template>
<BTableSimple caption-top>
<BThead>
<BTr>
<BTh
v-for="header in headers"
:key="header.key"
:width="header.width"
class="cursor"
:class="header.thClasses"
valign="top"
@click="doSort(header.orderKey ?? header.key)"
>
<SortButton
:current-order="order"
:current-sort="sort"
:order="header.orderKey ?? header.key"
:label="header.label ?? header.renderLabel(rows)"
/>
</BTh>
</BTr>
</BThead>
<BTbody>
<BTr
v-for="(row, key) in rows"
:key="key"
>
<BTd
v-for="header in headers"
:key="header.key"
class="cursor"
:class="header.tdClasses"
@click="rowClick(row)"
>
<!-- eslint-disable vue/no-v-html -->
<span v-if="header.key">
<span
v-if="header.render"
v-html="header.render(row)"
></span>
<span
v-else
v-text="row[header.key]"
></span>
</span>
<span
v-else
v-html="header.render(row)"
></span>
</BTd>
</BTr>
</BTbody>
</BTableSimple>
</template>
<script setup>
import { BTableSimple, BThead, BTbody, BTr, BTh, BTd } from 'bootstrap-vue-next'
import SortButton from '../SortButton.vue'
defineProps({
headers: {
type: Array,
required: true,
},
rows: {
type: Array,
required: true,
},
order: {
type: [String, null],
required: false,
default: null,
},
sort: {
type: [String, null],
required: false,
default: null,
},
})
const emit = defineEmits(['sort', 'rowClick'])
const doSort = (key) => {
emit('sort', key)
}
const rowClick = (row) => {
emit('rowClick', row)
}
</script>

View file

@ -0,0 +1,28 @@
<template>
<template
v-for="(field, key) in form.fields"
:key="key"
>
<FormWidget
v-if="fields.length === 0 || fields.includes(field.key)"
:form="form"
:field="field"
/>
</template>
</template>
<script setup>
import FormWidget from './FormWidget.vue'
defineProps({
form: {
type: Object,
required: true,
},
fields: {
type: Array,
required: false,
default: () => [],
},
})
</script>

View file

@ -0,0 +1,156 @@
<template>
<BFormGroup
:id="'form-label-' + key"
:key="key"
class="mb-2"
:label="field.label"
:label-for="'form-label-' + key"
:description="field.description"
>
<BFormInput
v-if="(field.widget ?? 'text') === 'text'"
:id="'form-input-' + key"
v-model="form.data[field.key]"
:type="field.type"
:required="field.required"
/>
<BFormFile
v-if="(field.widget ?? 'text') === 'file'"
:id="'form-input-' + key"
v-model="form.data[field.key]"
:type="field.type"
:required="field.required"
/>
<BFormSelect
v-if="(field.widget ?? 'text') === 'select'"
:id="'form-input-' + key"
v-model="form.data[field.key]"
:options="field.options"
:required="field.required"
/>
<div
v-if="field.widget === 'rules'"
id="rules"
>
<div v-if="form.data[field.key] !== null && form.data[field.key].length > 0">
<div
v-for="(rule, key) in form.data[field.key]"
:key="key"
class="p-3"
:class="{ 'border-bottom': key + 1 !== form.data[field.key].length }"
>
<div class="d-block d-lg-flex justify-content-between gap-1">
<BFormInput
v-if="form.data[field.key][key].id !== null"
v-model="form.data[field.key][key].id"
type="hidden"
/>
<BFormGroup
class="mb-2"
label="Libellé contient"
:label-for="'form-rule-contain-' + key"
>
<BFormInput
:id="'form-rule-contain-' + key"
v-model="form.data[field.key][key].contain"
/>
</BFormGroup>
<BFormGroup
class="mb-2"
label="Regex libellé"
:label-for="'form-rule-match-' + key"
>
<BFormInput
:id="'form-rule-match-' + key"
v-model="form.data[field.key][key].match"
/>
</BFormGroup>
<BFormGroup
class="mb-2"
label="Catégorie banque"
:label-for="'form-rule-contain-' + key"
>
<BFormInput
:id="'form-rule-bank-category-contain-' + key"
v-model="form.data[field.key][key].bank_category"
/>
</BFormGroup>
<BFormGroup
class="mb-2"
label="Montant"
:label-for="'form-rule-amount-' + key"
>
<BFormInput
:id="'form-rule-amount-' + key"
v-model="form.data[field.key][key].amount"
type="number"
/>
</BFormGroup>
<BFormGroup
class="mb-2"
label="Du"
:label-for="'form-rule-datefrom-' + key"
>
<BFormInput
:id="'form-rule-datefrom-' + key"
v-model="form.data[field.key][key].date_from"
type="date"
/>
</BFormGroup>
<BFormGroup
class="mb-2"
label="Au"
:label-for="'form-rule-dateto-' + key"
>
<BFormInput
:id="'form-rule-dateto-' + key"
v-model="form.data[field.key][key].date_to"
type="date"
/>
</BFormGroup>
<BButton
variant="none"
@click="form.data[field.key] = doRemoveRule(form.data[field.key], key)"
>
<i class="fa fa-trash"></i>
</BButton>
</div>
</div>
</div>
</div>
</BFormGroup>
</template>
<script setup>
import { BFormGroup, BFormSelect, BFormInput, BFormFile, BButton } from 'bootstrap-vue-next'
defineProps({
key: {
required: true,
},
form: {
type: Object,
required: true,
},
field: {
type: Object,
required: true,
},
})
const doRemoveRule = (item, key) => {
let values = []
item.forEach((v, k) => {
if (k !== key) {
values.push(v)
}
})
return values
}
</script>

View file

@ -1,12 +0,0 @@
<template>
<div class="d-block d-md-flex justify-content-between p-3">
<h3>{{ title }}</h3>
<slot name="menu"></slot>
</div>
</template>
<script setup>
import {defineProps} from 'vue'
defineProps(['title'])
</script>

View file

@ -21,14 +21,27 @@
</template>
<script setup>
import {ref, watch, defineProps} from 'vue'
import {getStorage, saveStorage} from '../../lib/storage'
import {compute} from '../../chart/capital'
import {Line} from 'vue-chartjs'
import {BFormSelect} from 'bootstrap-vue-next'
import {chartStyle} from '../../lib/chartStyle'
import { ref, watch } from 'vue'
import { getStorage, saveStorage } from '../../lib/storage'
import { compute } from '../../chart/capital'
import { Line } from 'vue-chartjs'
import { BFormSelect } from 'bootstrap-vue-next'
import { chartStyle } from '../../lib/chartStyle'
defineProps(['data', 'dateFrom', 'dateTo'])
defineProps({
data: {
type: Array,
required: true,
},
dateFrom: {
type: [String, null],
required: true,
},
dateTo: {
type: [String, null],
required: true,
},
})
const precision = ref(getStorage('dashboard:capital:precision'))
@ -36,9 +49,9 @@ watch(precision, (v) => saveStorage('dashboard:capital:precision', v))
const precisions = () => {
return [
{value: 'month', text: 'Mois'},
{value: '2weeks', text: '2 semaines'},
{value: 'day', text: 'Jour'},
{ value: 'month', text: 'Mois' },
{ value: '2weeks', text: '2 semaines' },
{ value: 'day', text: 'Jour' },
]
}

View file

@ -19,8 +19,10 @@
<BTh
width="170px"
class="text-end"
v-html="renderLabelWithSum('Total', computed(data, dateFrom, dateTo, order), 'sum')"></BTh
>
<!-- eslint-disable vue/no-v-html -->
<span v-html="renderLabelWithSum('Total', computed(data, dateFrom, dateTo, order), 'sum')"></span>
</BTh>
<BTh
width="250px"
class="text-end"
@ -46,10 +48,10 @@
v-for="item in computed(data, dateFrom, dateTo, order)"
:key="item.category.id"
>
<BTd
class="text-start"
v-html="renderCategory(item.category)"
></BTd>
<BTd class="text-start">
<!-- eslint-disable vue/no-v-html -->
<span v-html="renderCategory(item.category)"></span>
</BTd>
<BTd class="text-end">{{ renderEuro(item.sum) }}</BTd>
<BTd class="text-end">{{ renderEuro(item.average) }}</BTd>
<BTd class="text-end">{{ renderEuro(item.monthAverage) }}</BTd>
@ -67,30 +69,18 @@
</template>
<script setup>
import {ref, watch} from 'vue'
import {getStorage, saveStorage} from '../../lib/storage'
import {
BFormSelect,
BTableSimple,
BThead,
BTbody,
BTr,
BTh,
BTd,
} from 'bootstrap-vue-next'
import {compute} from '../../chart/debitAverage'
import {
renderLabelWithSum,
renderCategory,
renderEuro
} from '../../lib/renderers'
import { ref, watch } from 'vue'
import { getStorage, saveStorage } from '../../lib/storage'
import { BFormSelect, BTableSimple, BThead, BTbody, BTr, BTh, BTd } from 'bootstrap-vue-next'
import { compute } from '../../chart/debitAverage'
import { renderLabelWithSum, renderCategory, renderEuro } from '../../lib/renderers'
const order = ref(getStorage('dashboard:debitAverage:order', 'sum'))
const orders = [
{value: 'sum', text: 'Total'},
{value: 'average', text: 'Débit moyen / transaction'},
{value: 'monthAverage', text: 'Débit moyen / mois'},
{value: 'count', text: 'Transactions'},
{ value: 'sum', text: 'Total' },
{ value: 'average', text: 'Débit moyen / transaction' },
{ value: 'monthAverage', text: 'Débit moyen / mois' },
{ value: 'count', text: 'Transactions' },
]
let cache = null
@ -111,7 +101,20 @@ const computed = (data, dateFrom, dateTo, order) => {
watch(order, (v) => saveStorage('dashboard:debitAverage:order', v))
defineProps(['data', 'dateFrom', 'dateTo'])
defineProps({
data: {
type: Array,
required: true,
},
dateFrom: {
type: [String, null],
required: true,
},
dateTo: {
type: [String, null],
required: true,
},
})
</script>
<style scoped>

View file

@ -63,19 +63,31 @@
</template>
<script setup>
import {toRefs, defineProps, defineEmits} from 'vue'
import {BFormInput} from 'bootstrap-vue-next'
import { toRefs } from 'vue'
import { BFormInput } from 'bootstrap-vue-next'
const emit = defineEmits([
'update:account',
'update:dateFrom',
'update:dateTo',
'update',
])
const emit = defineEmits(['update:account', 'update:dateFrom', 'update:dateTo', 'update'])
const props = defineProps(['account', 'accounts', 'dateFrom', 'dateTo'])
const props = defineProps({
account: {
type: [Object, null],
required: true,
},
accounts: {
type: Array,
required: true,
},
dateFrom: {
type: [String, null],
required: true,
},
dateTo: {
type: [String, null],
required: true,
},
})
const {dateFrom, dateTo} = toRefs(props)
const { dateFrom, dateTo } = toRefs(props)
const change = (event, value) => {
emit(event, value)

View file

@ -13,11 +13,24 @@
</template>
<script setup>
import {Bar} from 'vue-chartjs'
import {compute} from '../../chart/diffCreditDebit'
import {chartStyle} from '../../lib/chartStyle'
import { Bar } from 'vue-chartjs'
import { compute } from '../../chart/diffCreditDebit'
import { chartStyle } from '../../lib/chartStyle'
defineProps(['data', 'dateFrom', 'dateTo'])
defineProps({
data: {
type: Array,
required: true,
},
dateFrom: {
type: [String, null],
required: true,
},
dateTo: {
type: [String, null],
required: true,
},
})
const options = () => {
return {

View file

@ -7,9 +7,10 @@
v-for="(item, key) in categories"
:key="key"
class="col-auto checkbox"
:class="{'checkbox--unchecked': !selectedCategories[item.label]}"
:class="{ 'checkbox--unchecked': !selectedCategories[item.label] }"
>
<BFormCheckbox v-model="selectedCategories[item.label]">
<!-- eslint-disable vue/no-v-html -->
<span
class="cursor"
v-html="renderCategory(item)"
@ -42,7 +43,7 @@
<div class="col-12 col-lg-4">
<Doughnut
:data="computeDoughnut(data, dateFrom, dateTo, selectedCategories)"
:options="fixedOptions({plugins: {legend: {display: false}}})"
:options="fixedOptions({ plugins: { legend: { display: false } } })"
:style="chartStyle(380)"
/>
</div>
@ -52,10 +53,8 @@
</div>
<div>
<Bar
:data="
computeBar(data, isStacked, dateFrom, dateTo, selectedCategories)
"
:options="stackOptions({plugins: {legend: {display: false}}})"
:data="computeBar(data, isStacked, dateFrom, dateTo, selectedCategories)"
:options="stackOptions({ plugins: { legend: { display: false } } })"
:style="chartStyle(380)"
/>
</div>
@ -65,19 +64,36 @@
</template>
<script setup>
import {ref, toRefs, watch, defineProps, onMounted} from 'vue'
import {getStorage, saveStorage} from '../../lib/storage'
import {computeDoughnut, computeBar} from '../../chart/distribution'
import {renderCategory} from '../../lib/renderers'
import {Doughnut, Bar} from 'vue-chartjs'
import {BFormCheckbox} from 'bootstrap-vue-next'
import {chartStyle} from '../../lib/chartStyle'
import { ref, toRefs, watch, onMounted } from 'vue'
import { getStorage, saveStorage } from '../../lib/storage'
import { computeDoughnut, computeBar } from '../../chart/distribution'
import { renderCategory } from '../../lib/renderers'
import { Doughnut, Bar } from 'vue-chartjs'
import { BFormCheckbox } from 'bootstrap-vue-next'
import { chartStyle } from '../../lib/chartStyle'
const props = defineProps(['data', 'categories', 'dateFrom', 'dateTo'])
const props = defineProps({
data: {
type: Array,
required: true,
},
categories: {
type: Array,
required: true,
},
dateFrom: {
type: [String, null],
required: true,
},
dateTo: {
type: [String, null],
required: true,
},
})
const isStacked = ref(getStorage('dashboard:distribution:isStacked'))
const selectedCategories = ref({})
const {categories} = toRefs(props)
const { categories } = toRefs(props)
watch(isStacked, (v) => saveStorage('dashboard:distribution:isStacked', v))
@ -92,11 +108,11 @@ const fixedOptions = (opts) => {
}
const stackOptions = (opts) => {
let options = {...fixedOptions(opts)}
let options = { ...fixedOptions(opts) }
options.scales = {
y: {stacked: true, ticks: {beginAtZero: true}},
x: {stacked: true},
y: { stacked: true, ticks: { beginAtZero: true } },
x: { stacked: true },
}
return options

View file

@ -33,19 +33,18 @@
</BThead>
<BTbody>
<template v-for="(item, date) in data">
<BTr v-for="data in item">
<BTr
v-for="(row, key) in item"
:key="key"
>
<BTd class="text-start">{{ date }}</BTd>
<BTd
class="text-start"
v-html="renderCategory(data.category)"
></BTd>
<BTd class="text-end">{{
renderEuro(data.category.month_threshold)
}}</BTd>
<BTd class="text-end">{{ renderEuro(data.amount) }}</BTd>
<BTd class="text-end">{{
renderEuro(data.amount - data.category.month_threshold)
}}</BTd>
<BTd class="text-start">
<!-- eslint-disable vue/no-v-html -->
<span v-html="renderCategory(row.category)"></span>
</BTd>
<BTd class="text-end">{{ renderEuro(row.category.month_threshold) }}</BTd>
<BTd class="text-end">{{ renderEuro(row.amount) }}</BTd>
<BTd class="text-end">{{ renderEuro(row.amount - row.category.month_threshold) }}</BTd>
</BTr>
</template>
</BTbody>
@ -60,8 +59,13 @@
</template>
<script setup>
import {BTableSimple, BThead, BTbody, BTr, BTh, BTd} from 'bootstrap-vue-next'
import {renderCategory, renderEuro} from '../../lib/renderers'
import { BTableSimple, BThead, BTbody, BTr, BTh, BTd } from 'bootstrap-vue-next'
import { renderCategory, renderEuro } from '../../lib/renderers'
defineProps(['data'])
defineProps({
data: {
type: Array,
required: true,
},
})
</script>

View file

@ -12,12 +12,16 @@
</template>
<script setup>
import {defineProps} from 'vue'
import {compute} from '../../chart/savingAccount'
import {Bar} from 'vue-chartjs'
import {chartStyle} from '../../lib/chartStyle'
import { compute } from '../../chart/savingAccount'
import { Bar } from 'vue-chartjs'
import { chartStyle } from '../../lib/chartStyle'
defineProps(['data'])
defineProps({
data: {
type: Array,
required: true,
},
})
const options = () => {
return {
@ -25,8 +29,8 @@ const options = () => {
maintainAspectRatio: true,
indexAxis: 'y',
scales: {
y: {stacked: true, ticks: {beginAtZero: true}},
x: {stacked: true},
y: { stacked: true, ticks: { beginAtZero: true } },
x: { stacked: true },
},
}
}

View file

@ -5,4 +5,4 @@ const chartStyle = (height) => {
}
}
export {chartStyle}
export { chartStyle }

View file

@ -12,4 +12,4 @@ const isInRange = (date, dateFrom, dateTo) => {
return true
}
export {isInRange}
export { isInRange }

View file

@ -20,4 +20,4 @@ const appendRequestQueryFilters = (data, route) => {
return data
}
export {queryFilters, appendRequestQueryFilters}
export { queryFilters, appendRequestQueryFilters }

View file

@ -3,10 +3,10 @@ const defineLimits = () => {
for (let i of [10, 20, 50, 100, 0]) {
let label = i !== 0 ? i : 'Tout'
v.push({value: i, text: label})
v.push({ value: i, text: label })
}
return v
}
export {defineLimits}
export { defineLimits }

View file

@ -24,7 +24,7 @@ const renderDateTime = (value) => {
const renderCategory = (item) => {
if (item !== null) {
return `<span class="badge" style="background: ${item.color}">&nbsp;</span> ${item.label}`
return `<span class="fa-solid fa-square" style="color: ${item.color}" b>&nbsp;</span> ${item.label}`
}
}
@ -62,11 +62,4 @@ const renderLabelWithSum = (label, rows, key) => {
return `${label}<br><small class="fw-normal">${renderEuro(sum)}</small>`
}
export {
renderDate,
renderDateTime,
renderCategory,
renderBankAccount,
renderEuro,
renderLabelWithSum,
}
export { renderDate, renderDateTime, renderCategory, renderBankAccount, renderEuro, renderLabelWithSum }

View file

@ -11,6 +11,25 @@ const createRequestOptions = (options) => {
return options
}
export {
createRequestOptions
const requestCallback = async (endpoint, options, callback) => {
return fetch(endpoint, createRequestOptions(options))
.then((response) => response.json())
.then(callback)
}
const request = async (endpoint, options) => {
return fetch(endpoint, createRequestOptions(options)).then((response) => response.json())
}
const requestErrorBuilder = (response) => {
let value = response.message
const errors = Object.values(response.errors ?? {})
if (errors.length) {
value += `<ul class="px-3 py-0 my-0 pt-1">${errors.map((e) => `<li class="m-0 p-0">${e}</li>`).join('')}</ul>`
}
return value
}
export { createRequestOptions, request, requestCallback, requestErrorBuilder }

View file

@ -43,4 +43,4 @@ const removeStorage = function (key) {
localStorage.removeItem(key)
}
export {getStorage, saveStorage, removeStorage}
export { getStorage, saveStorage, removeStorage }

View file

@ -1,36 +1,21 @@
import '../scss/main.scss'
import {createApp} from 'vue'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import {createBootstrap} from 'bootstrap-vue-next'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
ArcElement,
LineElement,
PointElement,
} from 'chart.js'
import { createBootstrap } from 'bootstrap-vue-next'
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, LineElement, PointElement } from 'chart.js'
ChartJS.register(
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
ArcElement,
LineElement,
PointElement,
)
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, LineElement, PointElement)
router.beforeEach((to, from, next) => {
if (to.meta?.label) {
document.title = `${to.meta.label} - Budget`
}
next()
})
const app = createApp(App)
app.use(createBootstrap())
app.use(router)
app.mount('#app')

View file

@ -0,0 +1,81 @@
import { requestCallback, request } from '../lib/request'
const endpoints = {
list: '/api/bank_account',
item: '/api/bank_account',
}
const getList = async (query, callback) => {
return requestCallback(`${endpoints.list}?${new URLSearchParams(query)}`, {}, callback ?? (() => true))
}
const getOne = async (query, callback) => {
return request(`${endpoints.list}?${new URLSearchParams(query)}`, {})
.then((data) => data.rows[0] ?? null)
.then(callback ?? (() => true))
}
const write = async (endpoint, data, callback, callbackError) => {
return requestCallback(
endpoint,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
},
callback ?? (() => true),
).catch(callbackError ?? (() => true))
}
const create = async (data, callback, callbackError) => {
return write(`${endpoints.item}`, data, callback, callbackError)
}
const update = async (data, callback, callbackError) => {
return write(`${endpoints.item}/${data.id}`, data, callback, callbackError)
}
const remove = async (id, callback, callbackError) => {
return requestCallback(
`${endpoints.item}/${id}`,
{
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
callback ?? (() => true),
).catch(callbackError ?? (() => true))
}
const createForm = (item) => {
const data = { ...item }
return {
data: data,
error: null,
fields: [
{
label: 'Libellé',
type: 'text',
required: true,
key: 'label',
},
],
}
}
const getListFields = () => {
return [
{
key: 'label',
label: 'Libellé',
},
]
}
export { endpoints, getList, getOne, create, update, remove, getListFields, createForm }

View file

@ -0,0 +1,193 @@
import { requestCallback, request } from '../lib/request'
import { renderCategory, renderEuro, renderLabelWithSum } from '../lib/renderers'
const endpoints = {
list: '/api/category',
item: '/api/category',
}
const getList = async (query, callback) => {
return requestCallback(`${endpoints.list}?${new URLSearchParams(query)}`, {}, callback ?? (() => true))
}
const getOne = async (query, callback) => {
return request(`${endpoints.list}?${new URLSearchParams(query)}`, {})
.then((data) => data.rows[0] ?? null)
.then(callback ?? (() => true))
}
const write = async (endpoint, data, callback, callbackError) => {
return requestCallback(
endpoint,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(fixData(data)),
},
callback ?? (() => true),
).catch(callbackError ?? (() => true))
}
const create = async (data, callback, callbackError) => {
return write(`${endpoints.item}`, data, callback, callbackError)
}
const update = async (data, callback, callbackError) => {
return write(`${endpoints.item}/${data.id}`, data, callback, callbackError)
}
const fixData = (data) => {
if (data.month_threshold !== null && data.month_threshold !== '') {
data.month_threshold = parseFloat(data.month_threshold)
} else {
data.month_threshold = null
}
if (data.rules === null) {
data.rules = []
}
data.rules.forEach((value, key) => {
if (value.amount !== null && value.amount !== '') {
data.rules[key].amount = parseFloat(value.amount)
}
if (value.date_from) {
data.rules[key].date_from = toISODateString(value.date_from, 0, 0, 0)
}
if (value.date_to) {
data.rules[key].date_to = toISODateString(value.date_to, 23, 59, 59)
}
for (let i in value) {
if (value[i] === '') {
data.rules[key][i] = null
}
}
})
data.ignore_transactions = data.ignore_transactions === 1
return data
}
const remove = async (id, callback, callbackError) => {
return requestCallback(
`${endpoints.item}/${id}`,
{
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
callback ?? (() => true),
).catch(callbackError ?? (() => true))
}
const createForm = (item) => {
if (!item) {
item = {
id: null,
label: null,
color: '#9eb1e7',
rules: [],
month_threshold: null,
ignore_transactions: 0,
}
}
const data = { ...item }
if (data.rules) {
data.rules.forEach((value, key) => {
if (value.date_from) {
data.rules[key].date_from = fromISODateString(value.date_from)
}
if (value.date_to) {
data.rules[key].date_to = fromISODateString(value.date_to)
}
})
}
data.ignore_transactions = data.ignore_transactions ? 1 : 0
return {
data: data,
error: null,
fields: [
{
label: 'Libellé',
type: 'text',
required: true,
key: 'label',
},
{
label: 'Seuil mensuel',
type: 'number',
required: false,
key: 'month_threshold',
},
{
label: 'Ignorer les transactions liées',
widget: 'select',
required: false,
options: [
{ value: 0, text: 'Non' },
{ value: 1, text: 'Oui' },
],
key: 'ignore_transactions',
},
{
label: 'Couleur',
type: 'color',
required: true,
key: 'color',
},
{
label: null,
widget: 'rules',
required: true,
key: 'rules',
},
],
}
}
const getListFields = () => {
return [
{
key: 'label',
label: 'Libellé',
render: (item) => renderCategory(item),
},
{
key: 'month_threshold',
renderLabel: (rows) => renderLabelWithSum('Seuil mensuel', rows, 'month_threshold'),
width: '150px',
thClasses: ['text-end'],
tdClasses: ['text-end'],
render: (item) => renderEuro(item.month_threshold),
},
]
}
const toISODateString = (v, h, m, s) => {
const d = new Date(v)
d.setUTCHours(h)
d.setUTCMinutes(m)
d.setUTCSeconds(s)
return d.toISOString()
}
const fromISODateString = (v) => {
return v.split('T', 1)[0]
}
export { endpoints, getList, getOne, create, update, remove, getListFields, createForm }

View file

@ -0,0 +1,135 @@
import { requestCallback, request } from '../lib/request'
import { renderEuro, renderLabelWithSum } from '../lib/renderers'
const endpoints = {
list: '/api/saving_account',
item: '/api/saving_account',
}
const getList = async (query, callback) => {
return requestCallback(`${endpoints.list}?${new URLSearchParams(query)}`, {}, callback ?? (() => true))
}
const getOne = async (query, callback) => {
return request(`${endpoints.list}?${new URLSearchParams(query)}`, {})
.then((data) => data.rows[0] ?? null)
.then(callback ?? (() => true))
}
const write = async (endpoint, data, callback, callbackError) => {
return requestCallback(
endpoint,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(fixData(data)),
},
callback ?? (() => true),
).catch(callbackError ?? (() => true))
}
const create = async (data, callback, callbackError) => {
return write(`${endpoints.item}`, fixData(data), callback, callbackError)
}
const update = async (data, callback, callbackError) => {
return write(`${endpoints.item}/${data.id}`, data, callback, callbackError)
}
const remove = async (id, callback, callbackError) => {
return requestCallback(
`${endpoints.item}/${id}`,
{
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
callback ?? (() => true),
).catch(callbackError ?? (() => true))
}
const fixData = (data) => {
if (data.blocked_amount) {
data.blocked_amount = parseFloat(data.blocked_amount)
} else {
data.blocked_amount = 0.0
}
if (data.released_amount) {
data.released_amount = parseFloat(data.released_amount)
} else {
data.released_amount = 0.0
}
return data
}
const createForm = (item) => {
if (!item) {
item = {
id: null,
label: null,
color: '#9eb1e7',
rules: [],
month_threshold: null,
ignore_transactions: 0,
}
}
const data = { ...item }
return {
data: data,
error: null,
fields: [
{
label: 'Libellé',
type: 'text',
required: true,
key: 'label',
},
{
label: 'Montant bloqué',
type: 'number',
key: 'blocked_amount',
},
{
label: 'Montant débloqué',
type: 'number',
key: 'released_amount',
},
],
}
}
const getListFields = () => {
return [
{
key: 'label',
label: 'Libellé',
},
{
key: 'blocked_amount',
renderLabel: (rows) => renderLabelWithSum('Montant bloqué', rows, 'blocked_amount'),
width: '200px',
thClasses: ['text-end'],
tdClasses: ['text-end'],
render: (item) => renderEuro(item.blocked_amount),
},
{
key: 'released_amount',
renderLabel: (rows) => renderLabelWithSum('Montant débloqué', rows, 'released_amount'),
width: '200px',
thClasses: ['text-end'],
tdClasses: ['text-end'],
render: (item) => renderEuro(item.released_amount),
},
]
}
export { endpoints, getList, getOne, create, update, remove, createForm, getListFields }

View file

@ -0,0 +1,164 @@
import { requestCallback, request } from '../lib/request'
import { renderDate, renderCategory, renderBankAccount, renderEuro, renderLabelWithSum } from '../lib/renderers'
const endpoints = {
list: '/api/transaction',
items: '/api/transactions',
updateCategories: '/api/transactions/update_categories',
}
const getList = async (query, callback) => {
return requestCallback(`${endpoints.list}?${new URLSearchParams(query)}`, {}, callback ?? (() => true))
}
const getOne = async (query, callback) => {
return request(`${endpoints.list}?${new URLSearchParams(query)}`, {})
.then((data) => data.rows[0] ?? null)
.then(callback ?? (() => true))
}
const importFile = async (data, callback, callbackError) => {
return requestCallback(
endpoints.items,
{
method: 'POST',
headers: {
Accept: 'application/json',
},
body: createPayload(data),
},
callback ?? (() => true),
).catch(callbackError ?? (() => true))
}
const createPayload = (data) => {
const payload = new FormData()
payload.append('bank_account_id', data.bank_account_id)
payload.append('file', data.file)
payload.append('format', data.format)
return payload
}
const createForm = (accounts) => {
const data = { category_id: null, file: null, format: 'caisse_epargne' }
const options = []
accounts.forEach((item) => {
options.push({
value: item.id,
text: item.label,
})
})
return {
data: data,
error: null,
fields: [
{
label: 'Compte bancaire',
widget: 'select',
options: options,
required: true,
key: 'bank_account_id',
},
{
label: 'Format de fichier',
key: 'format',
widget: 'select',
options: [
{ value: 'caisse_epargne', text: "Caisse d'épargne" },
{ value: 'banque_populaire', text: "Banque Populaire" },
{ value: 'revolut', text: 'Revolut' },
],
},
{
label: 'Fichier',
description: `Fichier CSV des opérations`,
widget: 'file',
required: true,
key: 'file',
},
],
}
}
const updateCategories = async (callback, callbackError) => {
return requestCallback(
`${endpoints.updateCategories}`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
callback ?? (() => true),
).catch(callbackError ?? (() => true))
}
const getListFields = () => {
return [
{
key: 'date',
label: 'Date',
width: '90px',
render: (item) => renderDate(item.date),
},
{
key: 'label',
label: 'Libellé',
},
{
key: 'operation_type',
width: '200px',
label: 'Type',
},
{
key: 'category',
label: 'Catégorie',
orderKey: 'category_id',
width: '400px',
render: (item) => renderCategory(item.category),
},
{
key: 'bank_account',
label: 'Compte',
width: '200px',
orderKey: 'bank_account_id',
render: (item) => renderBankAccount(item.bank_account),
},
{
key: 'debit',
renderLabel: (rows) => renderLabelWithSum('Débit', rows, 'debit'),
width: '120px',
thClasses: ['text-end'],
tdClasses: ['text-end'],
render: (item) => renderEuro(item.debit),
},
{
key: 'credit',
renderLabel: (rows) => renderLabelWithSum('Crédit', rows, 'credit'),
width: '120px',
thClasses: ['text-end'],
tdClasses: ['text-end'],
render: (item) => renderEuro(item.credit),
},
]
}
const getListFilters = () => {
return [
{ key: 'label', type: 'string', label: 'Libellé' },
{ key: 'debit', type: 'number', label: 'Débit' },
{ key: 'credit', type: 'number', label: 'Crédit' },
{ key: 'date', type: 'date', label: 'Date' },
{ key: 'operation_type', type: 'string', label: 'Type' },
{ key: 'category_id', type: 'select', label: 'Catégorie', options: [] },
{ key: 'bank_account_id', type: 'select', label: 'Compte', options: [] },
]
}
export { endpoints, getList, getOne, createForm, getListFields, getListFilters, importFile, updateCategories }

104
frontend/js/models/user.js Normal file
View file

@ -0,0 +1,104 @@
import { requestCallback, request } from '../lib/request'
import { renderDateTime } from '../lib/renderers'
const endpoints = {
list: '/api/user',
item: '/api/user',
}
const getList = async (query, callback) => {
return requestCallback(`${endpoints.list}?${new URLSearchParams(query)}`, {}, callback ?? (() => true))
}
const getOne = async (query, callback) => {
return request(`${endpoints.list}?${new URLSearchParams(query)}`, {})
.then((data) => data.rows[0] ?? null)
.then(callback ?? (() => true))
}
const write = async (endpoint, data, callback, callbackError) => {
return requestCallback(
endpoint,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
},
callback ?? (() => true),
).catch(callbackError ?? (() => true))
}
const create = async (data, callback, callbackError) => {
return write(`${endpoints.item}`, data, callback, callbackError)
}
const update = async (data, callback, callbackError) => {
return write(`${endpoints.item}/${data.id}`, data, callback, callbackError)
}
const remove = async (id, callback, callbackError) => {
return requestCallback(
`${endpoints.item}/${id}`,
{
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
callback ?? (() => true),
).catch(callbackError ?? (() => true))
}
const createForm = (item) => {
const data = { ...item }
return {
data: data,
error: null,
fields: [
{
label: 'Nom',
type: 'text',
required: true,
key: 'display_name',
},
{
label: "Nom d'utilisateur",
type: 'text',
required: true,
key: 'username',
},
{
label: 'Mot de passe',
type: 'password',
required: data.id > 0,
key: 'password',
},
],
}
}
const getListFields = () => {
return [
{
key: 'display_name',
label: 'Nom',
width: '30%',
},
{
key: 'username',
label: 'Utilisateur',
},
{
key: 'logged_at',
label: 'Dernière connexion',
render: (item) => renderDateTime(item.logged_at),
},
]
}
export { endpoints, getList, getOne, create, update, remove, getListFields, createForm }

View file

@ -1,48 +1,137 @@
import {createRouter, createWebHashHistory} from 'vue-router'
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'Tableau de bord',
meta: {icon: ['fa-solid', 'fa-chart-line']},
name: 'dashboard',
meta: { label: 'Tableau de bord', icon: ['fa-solid', 'fa-chart-line'] },
component: () => import('../views/DashboardView.vue'),
},
{
path: '/transactions',
name: 'Transactions',
meta: {icon: ['fa-solid', 'fa-money-bill-transfer']},
component: () => import('../views/TransactionsView.vue'),
name: 'transactions',
meta: {
label: 'Transactions',
icon: ['fa-solid', 'fa-money-bill-transfer'],
},
component: () => import('../views/transaction/ListView.vue'),
children: [
{
path: '/transaction/:id',
props: true,
name: 'transaction_show',
component: () => import('../views/transaction/ShowView.vue'),
},
],
},
{
path: '/users',
name: 'Utilisateurs',
meta: {icon: ['fa-solid', 'fa-users']},
component: () => import('../views/UsersView.vue'),
component: () => import('../views/ComponentView.vue'),
children: [
{
path: '',
name: 'users',
meta: { label: 'Utilisateurs', icon: ['fa-solid', 'fa-users'] },
component: () => import('../views/user/ListView.vue'),
},
{
path: '/user/edit/:id',
name: 'user_edit',
meta: { label: 'Utilisateur' },
component: () => import('../views/user/EditView.vue'),
},
{
path: '/user/create',
name: 'user_create',
meta: { label: 'Nouvel utilisateur' },
component: () => import('../views/user/CreateView.vue'),
},
],
},
{
path: '/categories',
name: 'Catégories',
meta: {icon: ['fa-solid', 'fa-list']},
component: () => import('../views/CategoriesView.vue'),
component: () => import('../views/ComponentView.vue'),
children: [
{
path: '',
name: 'categories',
meta: { label: 'Catégories', icon: ['fa-solid', 'fa-list'] },
component: () => import('../views/category/ListView.vue'),
},
{
path: '/category/edit/:id/:transaction?',
name: 'category_edit',
meta: { label: 'Catégorie' },
component: () => import('../views/category/EditView.vue'),
},
{
path: '/category/create',
name: 'category_create',
meta: { label: 'Nouvelle catégorie' },
component: () => import('../views/category/CreateView.vue'),
},
],
},
{
path: '/bank_accounts',
name: 'Comptes bancaires',
meta: {icon: ['fa-solid', 'fa-piggy-bank']},
component: () => import('../views/BankAccountsView.vue'),
component: () => import('../views/ComponentView.vue'),
children: [
{
path: '',
name: 'bank_accounts',
meta: {
label: 'Comptes bancaires',
icon: ['fa-solid', 'fa-piggy-bank'],
},
component: () => import('../views/bank_account/ListView.vue'),
},
{
path: '/bank_account/edit/:id',
name: 'bank_account_edit',
meta: { label: 'Compte bancaire' },
component: () => import('../views/bank_account/EditView.vue'),
},
{
path: '/bank_account/create',
name: 'bank_account_create',
meta: { label: 'Nouveau compte bancaire' },
component: () => import('../views/bank_account/CreateView.vue'),
},
],
},
{
path: '/saving_accounts',
name: 'Comptes épargnes',
meta: {icon: ['fa-solid', 'fa-piggy-bank']},
component: () => import('../views/SavingAccountsView.vue'),
component: () => import('../views/ComponentView.vue'),
children: [
{
path: '',
name: 'saving_accounts',
meta: {
label: 'Comptes épargnes',
icon: ['fa-solid', 'fa-piggy-bank'],
},
component: () => import('../views/saving_account/ListView.vue'),
},
{
path: '/saving_account/edit/:id',
name: 'saving_account_edit',
meta: { label: 'Compte bancaire' },
component: () => import('../views/saving_account/EditView.vue'),
},
{
path: '/saving_account/create',
name: 'saving_account_create',
meta: { label: 'Nouveau compte épargne' },
component: () => import('../views/saving_account/CreateView.vue'),
},
],
},
{
path: '/files',
name: 'Fichiers',
meta: {icon: ['fa-solid', 'fa-table']},
name: 'files',
meta: { label: 'Fichiers', icon: ['fa-solid', 'fa-table'] },
component: () => import('../views/FilesView.vue'),
},
],

View file

@ -1,319 +0,0 @@
<template>
<BContainer
fluid
class="p-0"
>
<Header title="Comptes bancaires">
<template #menu>
<BButtonToolbar key-nav>
<BButton
variant="primary"
@click="doAdd"
>Ajouter</BButton
>
</BButtonToolbar>
</template>
</Header>
<Pager
v-model:page="page"
v-model:pages="pages"
v-model:limit="limit"
@update="refresh()"
/>
<div class="crud-list">
<BTableSimple
v-if="data !== null"
caption-top
responsive
>
<BThead>
<BTr>
<BTh
v-for="(field, key) in fields"
:key="key"
:width="field.width"
class="cursor"
:class="field.classes"
valign="top"
@click="doSort(field.key)"
>
<SortButton
:current-order="order"
:current-sort="sort"
:order="field.orderKey ?? field.key"
:label="field.label ?? field.renderLabel(data.rows)"
/>
</BTh>
</BTr>
</BThead>
<BTbody>
<BTr
v-for="row in data.rows"
:key="row.id"
>
<BTd
v-for="(field, key) in fields"
:key="key"
class="cursor"
@click="doEdit(row)"
>
<span v-if="field.key">
<span
v-if="field.render"
v-html="field.render(row)"
></span>
<span
v-else
v-text="row[field.key]"
></span>
</span>
<span
v-else
v-html="field.render(row)"
></span>
</BTd>
</BTr>
</BTbody>
</BTableSimple>
</div>
<BModal
v-if="form !== null"
v-model="formShow"
:title="form?.label"
footer-class="justify-content-between"
@ok="doSave"
>
<BAlert
:model-value="form.error !== null"
variant="danger"
v-text="form.error"
></BAlert>
<BForm @submit="doSave">
<BFormGroup
v-for="(field, key) in form.fields"
:id="'form-label-' + key"
:key="key"
class="mb-2"
:label="field.label"
:label-for="'form-label-' + key"
:description="field.description"
>
<BFormInput
v-if="(field.widget ?? 'text') === 'text'"
:id="'form-input-' + key"
v-model="form.data[field.key]"
:type="field.type"
:required="field.required"
/>
</BFormGroup>
</BForm>
<template #footer>
<div>
<BButton
v-if="form.data.id"
variant="danger"
@click="doDelete"
>Supprimer</BButton
>
</div>
<div>
<BButton
variant="secondary"
class="me-2"
@click="formShow = false"
>Annuler</BButton
>
<BButton
variant="primary"
@click="doSave"
>OK</BButton
>
</div>
</template>
</BModal>
</BContainer>
</template>
<script setup>
import {
BTbody,
BThead,
BTr,
BTd,
BTh,
BContainer,
BTableSimple,
BModal,
BButton,
BForm,
BFormGroup,
BFormInput,
BAlert,
BButtonToolbar,
} from 'bootstrap-vue-next'
import SortButton from './../components/SortButton.vue'
import Header from './../components/crud/Header.vue'
import Pager from './../components/crud/Pager.vue'
import {ref, onMounted, watch} from 'vue'
import {getStorage, saveStorage} from '../lib/storage'
import {createRequestOptions} from '../lib/request'
const endpoint = `/api/bank_account`
const order = ref(getStorage(`${endpoint}:order`))
const sort = ref(getStorage(`${endpoint}:sort`))
const page = ref(getStorage(`${endpoint}:page`))
const limit = ref(getStorage(`${endpoint}:limit`))
const data = ref(null)
const pages = ref(null)
const form = ref(null)
const formShow = ref(false)
watch(order, (v) => saveStorage(`${endpoint}:order`, v))
watch(sort, (v) => saveStorage(`${endpoint}:sort`, v))
watch(page, (v) => saveStorage(`${endpoint}:page`, v))
watch(limit, (v) => saveStorage(`${endpoint}:limit`, v))
const refresh = () => {
fetch(
`${endpoint}?${new URLSearchParams({
order: order.value,
sort: sort.value,
page: page.value,
limit: limit.value,
})}`,
createRequestOptions()
)
.then((response) => {
return response.json()
})
.then(function (value) {
data.value = value
order.value = value.order
sort.value = value.sort
page.value = value.page
pages.value = value.total_pages
limit.value = value.limit
})
}
const doEdit = (item) => {
const data = {...item}
form.value = {
action: `${endpoint}/${item.id}`,
method: 'POST',
data: data,
label: data.label,
error: null,
fields: [
{
label: 'Libellé',
type: 'text',
required: true,
key: 'label',
},
],
}
formShow.value = true
}
const doAdd = () => {
const data = {label: null}
form.value = {
action: `${endpoint}`,
method: 'POST',
data: data,
label: 'Nouveau',
error: null,
fields: [
{
label: 'Libellé',
type: 'text',
required: true,
key: 'label',
},
],
}
formShow.value = true
}
const doDelete = () => {
if (!confirm('Je confirme la suppression')) {
return
}
fetch(`${endpoint}/${form.value.data.id}`, createRequestOptions({
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})).then(() => {
formShow.value = false
refresh()
})
}
const doSave = (e) => {
e.preventDefault()
const url = form.value.data.id
? `${endpoint}/${form.value.data.id}`
: endpoint
fetch(url, createRequestOptions({
method: form.value.method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(form.value.data),
}))
.then((response) => {
return response.json()
})
.then((data) => {
if (data.code === 400) {
form.value.error = data.message
} else {
form.value = null
formShow.value = false
refresh()
}
})
.catch((err) => {
form.value.error = `Une erreur s'est produite : ${err}`
})
}
const doSort = (key) => {
let nextSort = 'asc'
if (order.value === key) {
nextSort = sort.value === 'asc' ? 'desc' : 'asc'
}
order.value = key
sort.value = nextSort
page.value = 1
refresh()
}
const fields = [
{
key: 'label',
label: 'Libellé',
},
]
onMounted(() => {
refresh()
})
</script>

View file

@ -1,648 +0,0 @@
<template>
<BContainer
fluid
class="p-0"
>
<Header title="Catégories">
<template #menu>
<BButtonToolbar key-nav>
<BButton
variant="secondary"
class="me-2"
@click="doApply"
>
<VueSpinner
v-if="applyInProgress"
size="20"
color="white"
/>
<span v-else>Appliquer&nbsp;les&nbsp;règles</span>
</BButton>
<BButton
variant="primary"
@click="doAdd"
>Ajouter</BButton
>
</BButtonToolbar>
</template>
</Header>
<Pager
v-model:page="page"
v-model:pages="pages"
v-model:limit="limit"
@update="refresh()"
/>
<div class="crud-list">
<BTableSimple
v-if="data !== null"
caption-top
responsive
>
<BThead>
<BTr>
<BTh
v-for="field in fields"
:key="field.key"
:width="field.width"
class="cursor"
:class="field.thClasses"
valign="top"
@click="doSort(field.key)"
>
<SortButton
:current-order="order"
:current-sort="sort"
:order="field.orderKey ?? field.key"
:label="field.label ?? field.renderLabel(data.rows)"
/>
</BTh>
</BTr>
</BThead>
<BTbody>
<BTr
v-for="(row, key) in data.rows"
:key="key"
>
<BTd
v-for="field in fields"
:key="field.key"
class="cursor"
:class="field.tdClasses"
@click="doEdit(row)"
>
<span v-if="field.key">
<span
v-if="field.render"
v-html="field.render(row)"
></span>
<span
v-else
v-text="row[field.key]"
></span>
</span>
<span
v-else
v-html="field.render(row)"
></span>
</BTd>
</BTr>
</BTbody>
</BTableSimple>
</div>
<BModal
v-if="form !== null"
ref="modal"
v-model="formShow"
scrollable
class="modal-xl"
:title="form?.label"
footer-class="justify-content-between"
@ok="doSave"
>
<BAlert
:model-value="form.error !== null"
variant="danger"
v-text="form.error"
></BAlert>
<BForm @submit="doSave">
<BFormGroup
v-for="(field, key) in form.fields"
:id="'form-label-' + key"
:key="key"
class="mb-2"
:label="field.label"
:label-for="'form-label-' + key"
:description="field.description"
>
<BFormInput
v-if="(field.widget ?? 'text') === 'text'"
:id="'form-input-' + key"
v-model="form.data[field.key]"
:type="field.type"
:required="field.required"
/>
<BFormSelect
v-if="(field.widget ?? 'text') === 'select'"
:id="'form-input-' + key"
v-model="form.data[field.key]"
:options="field.options"
:required="field.required"
/>
<div v-if="field.widget === 'rules'">
<div
v-if="
form.data[field.key] !== null && form.data[field.key].length > 0
"
class="list-group"
>
<div
v-for="(rule, key) in form.data[field.key]"
:key="key"
class="list-group-item"
>
<div class="d-block d-lg-flex justify-content-between gap-1">
<BFormInput
v-if="form.data[field.key][key].id !== null"
v-model="form.data[field.key][key].id"
type="hidden"
/>
<BFormGroup
class="mb-2"
label="Libellé contient"
:label-for="'form-rule-contain-' + key"
>
<BFormInput
:id="'form-rule-contain-' + key"
v-model="form.data[field.key][key].contain"
/>
</BFormGroup>
<BFormGroup
class="mb-2"
label="Regex libellé"
:label-for="'form-rule-match-' + key"
>
<BFormInput
:id="'form-rule-match-' + key"
v-model="form.data[field.key][key].match"
/>
</BFormGroup>
<BFormGroup
class="mb-2"
label="Catégorie banque"
:label-for="'form-rule-contain-' + key"
>
<BFormInput
:id="'form-rule-bank-category-contain-' + key"
v-model="form.data[field.key][key].bank_category"
/>
</BFormGroup>
<BFormGroup
class="mb-2"
label="Montant"
:label-for="'form-rule-amount-' + key"
>
<BFormInput
:id="'form-rule-amount-' + key"
v-model="form.data[field.key][key].amount"
type="number"
/>
</BFormGroup>
<BFormGroup
class="mb-2"
label="Du"
:label-for="'form-rule-datefrom-' + key"
>
<BFormInput
:id="'form-rule-datefrom-' + key"
v-model="form.data[field.key][key].date_from"
type="date"
/>
</BFormGroup>
<BFormGroup
class="mb-2"
label="Au"
:label-for="'form-rule-dateto-' + key"
>
<BFormInput
:id="'form-rule-dateto-' + key"
v-model="form.data[field.key][key].date_to"
type="date"
/>
</BFormGroup>
<BButton
variant="none"
@click="
form.data[field.key] = doRemoveRule(
form.data[field.key],
key,
)
"
>
<i class="fa fa-trash"></i>
</BButton>
</div>
</div>
</div>
</div>
</BFormGroup>
</BForm>
<template #footer>
<BButton
variant="secondary"
@click="form.data['rules'] = doAddRule(form.data['rules'])"
>Ajouter règle</BButton
>
<div>
<BButton
v-if="form.data.id"
variant="danger"
@click="doDelete"
>Supprimer</BButton
>
</div>
<div>
<BButton
variant="secondary"
class="me-2"
@click="formShow = false"
>Annuler</BButton
>
<BButton
variant="primary"
@click="doSave"
>OK</BButton
>
</div>
</template>
</BModal>
</BContainer>
</template>
<script setup>
import {
BTbody,
BThead,
BTr,
BTd,
BTh,
BContainer,
BTableSimple,
BModal,
BButton,
BForm,
BFormGroup,
BFormInput,
BAlert,
BButtonToolbar,
BFormSelect,
} from 'bootstrap-vue-next'
import {VueSpinner} from 'vue3-spinners'
import SortButton from './../components/SortButton.vue'
import Header from './../components/crud/Header.vue'
import Pager from './../components/crud/Pager.vue'
import {ref, onMounted, watch, useTemplateRef} from 'vue'
import {getStorage, saveStorage} from '../lib/storage'
import {renderCategory, renderEuro, renderLabelWithSum} from '../lib/renderers'
import {createRequestOptions} from '../lib/request'
const endpoint = `/api/category`
const order = ref(getStorage(`${endpoint}:order`))
const sort = ref(getStorage(`${endpoint}:sort`))
const page = ref(getStorage(`${endpoint}:page`))
const limit = ref(getStorage(`${endpoint}:limit`))
const data = ref(null)
const pages = ref(null)
const form = ref(null)
const formShow = ref(false)
const applyInProgress = ref(false)
const modal = useTemplateRef('modal')
watch(order, (v) => saveStorage(`${endpoint}:order`, v))
watch(sort, (v) => saveStorage(`${endpoint}:sort`, v))
watch(page, (v) => saveStorage(`${endpoint}:page`, v))
watch(limit, (v) => saveStorage(`${endpoint}:limit`, v))
const doRemoveRule = (item, key) => {
let values = []
item.forEach((v, k) => {
if (k !== key) {
values.push(v)
}
})
return values
}
const doAddRule = (item) => {
const rule = {
contain: null,
match: null,
bank_category: null,
amount: null,
date_from: null,
date_to: null,
}
if (item == null) {
item = []
}
item.push(rule)
window.setTimeout(() => {
const modalBody = document.querySelector('.modal-body')
modalBody.scrollTo({top: modalBody.scrollHeight, behavior: 'smooth'})
}, 300)
return item
}
const doApply = () => {
applyInProgress.value = true
fetch('/api/transactions/update_categories', createRequestOptions({method: 'POST'})).then(() => {
applyInProgress.value = false
})
}
const refresh = () => {
fetch(
`${endpoint}?${new URLSearchParams({
order: order.value,
sort: sort.value,
page: page.value,
limit: limit.value,
})}`,
createRequestOptions()
)
.then((response) => {
return response.json()
})
.then(function (value) {
data.value = value
order.value = value.order
sort.value = value.sort
page.value = value.page
pages.value = value.total_pages
limit.value = value.limit
})
}
const toISODateString = (v, h, m, s) => {
const d = new Date(v)
d.setUTCHours(h)
d.setUTCMinutes(m)
d.setUTCSeconds(s)
return d.toISOString()
}
const fromISODateString = (v) => {
return v.split('T', 1)[0]
}
const doEdit = (item) => {
const data = {...item}
if (data.rules !== null) {
data.rules.forEach((value, key) => {
if (value.date_from) {
data.rules[key].date_from = fromISODateString(value.date_from)
}
if (value.date_to) {
data.rules[key].date_to = fromISODateString(value.date_to)
}
})
}
data.ignore_transactions = data.ignore_transactions ? 1 : 0
form.value = {
action: `${endpoint}/${item.id}`,
method: 'POST',
data: data,
label: data.label,
error: null,
fields: [
{
label: 'Libellé',
type: 'text',
required: true,
key: 'label',
},
{
label: 'Seuil mensuel',
type: 'number',
required: false,
key: 'month_threshold',
},
{
label: 'Ignorer les transactions liées',
widget: 'select',
required: false,
options: [
{value: 0, text: 'Non'},
{value: 1, text: 'Oui'},
],
key: 'ignore_transactions',
},
{
label: 'Couleur',
type: 'color',
required: true,
key: 'color',
},
{
label: null,
widget: 'rules',
required: false,
key: 'rules',
},
],
}
formShow.value = true
}
const doAdd = () => {
const data = {
id: null,
label: null,
color: '#9eb1e7',
rules: [],
month_threshold: null,
ignore_transactions: 0,
}
form.value = {
action: `${endpoint}`,
method: 'POST',
data: data,
label: 'Nouveau',
error: null,
fields: [
{
label: null,
type: 'hidden',
required: false,
key: 'id',
},
{
label: 'Libellé',
type: 'text',
required: true,
key: 'label',
},
{
label: 'Seuil mensuel',
type: 'number',
required: false,
key: 'month_threshold',
},
{
label: 'Ignorer les transactions liées',
widget: 'select',
required: false,
options: [
{value: 0, text: 'Non'},
{value: 1, text: 'Oui'},
],
key: 'ignore_transactions',
},
{
label: 'Couleur',
type: 'color',
required: true,
key: 'color',
},
{
label: null,
widget: 'rules',
required: true,
key: 'rules',
},
],
}
formShow.value = true
}
const doDelete = () => {
if (!confirm('Je confirme la suppression')) {
return
}
fetch(`${endpoint}/${form.value.data.id}`, createRequestOptions({
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})).then(() => {
formShow.value = false
refresh()
})
}
const doSave = (e) => {
e.preventDefault()
const url = form.value.data.id
? `${endpoint}/${form.value.data.id}`
: endpoint
if (form.value.data.rules === null) {
form.value.data.rules = []
}
if (
form.value.data.month_threshold !== null &&
form.value.data.month_threshold !== ''
) {
form.value.data.month_threshold = parseFloat(
form.value.data.month_threshold,
)
} else {
form.value.data.month_threshold = null
}
form.value.data.ignore_transactions =
form.value.data.ignore_transactions === 1
form.value.data.rules.forEach((value, key) => {
if (value.amount !== null && value.amount !== '') {
form.value.data.rules[key].amount = parseFloat(value.amount)
}
if (value.date_from) {
form.value.data.rules[key].date_from = toISODateString(
value.date_from,
0,
0,
0,
)
}
if (value.date_to) {
form.value.data.rules[key].date_to = toISODateString(
value.date_to,
23,
59,
59,
)
}
for (let i in value) {
if (value[i] === '') {
form.value.data.rules[key][i] = null
}
}
})
fetch(url, createRequestOptions({
method: form.value.method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(form.value.data),
}))
.then((response) => {
return response.json()
})
.then((data) => {
if (data.code === 400) {
form.value.error = data.message
} else {
form.value = null
formShow.value = false
refresh()
}
})
.catch((err) => {
form.value.error = `Une erreur s'est produite : ${err}`
})
}
const doSort = (key) => {
let nextSort = 'asc'
if (order.value === key) {
nextSort = sort.value === 'asc' ? 'desc' : 'asc'
}
order.value = key
sort.value = nextSort
page.value = 1
refresh()
}
const fields = [
{
key: 'label',
label: 'Libellé',
render: (item) => renderCategory(item),
},
{
key: 'month_threshold',
renderLabel: (rows) =>
renderLabelWithSum('Seuil mensuel', rows, 'month_threshold'),
width: '150px',
thClasses: ['text-end'],
tdClasses: ['text-end'],
render: (item) => renderEuro(item.month_threshold),
},
]
onMounted(() => {
refresh()
})
</script>

View file

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View file

@ -1,13 +1,6 @@
<template>
<div class="p-3">
<div
v-if="
!isLoading &&
data !== null &&
accounts.length > 0 &&
categories.length > 0
"
>
<div v-if="!isLoading && data !== null && accounts.length > 0 && categories.length > 0">
<div class="float-end">
<BButton
variant="none"
@ -17,7 +10,7 @@
</BButton>
</div>
<Filters
<DashFilter
v-model:account="account"
v-model:date-from="dateFrom"
v-model:date-to="dateTo"
@ -49,50 +42,51 @@
>
<BButton
v-for="i in [
{size: 4, label: '4'},
{size: 6, label: '6'},
{size: 8, label: '8'},
{size: 10, label: '10'},
{size: 12, label: '12'},
{ size: 4, label: '4' },
{ size: 6, label: '6' },
{ size: 8, label: '8' },
{ size: 10, label: '10' },
{ size: 12, label: '12' },
]"
:key="i"
:variant="i.size == config[key].size ? 'primary' : 'secondary'"
@click="config[key].size = i.size; updateConfig()"
@click="setConfigSize(key, i.size)"
>{{ i.label }}</BButton
>
</BButtonGroup>
</div>
<Capital
v-if="item.component === 'Capital'"
<CapitalChart
v-if="item.component === 'CapitalChart'"
:data="data"
:date-from="dateFrom"
:date-to="dateTo"
/>
<SavingAccounts
v-if="item.component === 'SavingAccounts'"
<SavingAccountsChart
v-if="item.component === 'SavingAccountsChart'"
:data="savingAccounts"
/>
<Distribution
v-if="item.component === 'Distribution'"
<DistributionChart
v-if="item.component === 'DistributionChart'"
:data="data"
:categories="categories"
:date-from="dateFrom"
:date-to="dateTo"
/>
<DiffCreditDebit
v-if="item.component === 'DiffCreditDebit'"
<DiffCreditDebitChart
v-if="item.component === 'DiffCreditDebitChart'"
:data="data"
:date-from="dateFrom"
:date-to="dateTo"
/>
<MonthThresholds
v-if="item.component === 'MonthThresholds'"
<MonthThresholdsTable
v-if="item.component === 'MonthThresholdsTable'"
:data="monthThresholdsData()"
:date-from="dateFrom"
:date-to="dateTo"
/>
<CategoriesStats
v-if="item.component === 'CategoriesStats'"
<CategoriesChart
v-if="item.component === 'CategoriesChart'"
:data="data"
:date-from="dateFrom"
:date-to="dateTo"
@ -101,24 +95,30 @@
</transition-group>
</Draggable>
</div>
<div v-else>Chargement...</div>
<div
v-else
class="text-center p-5"
>
<p>Chargement...</p>
<BSpinner />
</div>
</div>
</template>
<script setup>
import {ref, reactive, onMounted, watch} from 'vue'
import {compute as monthThresholds} from '../chart/monthThreshold'
import {getStorage, saveStorage} from '../lib/storage'
import {BButtonGroup, BButton} from 'bootstrap-vue-next'
import Filters from './../components/dashboard/Filters.vue'
import Capital from './../components/dashboard/Capital.vue'
import SavingAccounts from './../components/dashboard/SavingAccounts.vue'
import Distribution from './../components/dashboard/Distribution.vue'
import MonthThresholds from './../components/dashboard/MonthThresholds.vue'
import DiffCreditDebit from './../components/dashboard/DiffCreditDebit.vue'
import CategoriesStats from './../components/dashboard/CategoriesStats.vue'
import {VueDraggableNext as Draggable} from 'vue-draggable-next'
import {createRequestOptions} from '../lib/request'
import { ref, onMounted, watch } from 'vue'
import { compute as monthThresholds } from '../chart/monthThreshold'
import { getStorage, saveStorage } from '../lib/storage'
import { BButtonGroup, BButton, BSpinner } from 'bootstrap-vue-next'
import DashFilter from './../components/dashboard/DashFilter.vue'
import CapitalChart from './../components/dashboard/CapitalChart.vue'
import SavingAccountsChart from './../components/dashboard/SavingAccountsChart.vue'
import DistributionChart from './../components/dashboard/DistributionChart.vue'
import MonthThresholdsTable from './../components/dashboard/MonthThresholdsTable.vue'
import DiffCreditDebitChart from './../components/dashboard/DiffCreditDebitChart.vue'
import CategoriesChart from './../components/dashboard/CategoriesChart.vue'
import { VueDraggableNext as Draggable } from 'vue-draggable-next'
import { createRequestOptions } from '../lib/request'
const data = ref(null)
const isLoading = ref(true)
@ -129,12 +129,12 @@ const savingAccounts = ref([])
const mode = ref('view')
const defaultComponents = [
{component: 'Capital', size: 8},
{component: 'SavingAccounts', size: 4},
{component: 'Distribution', size: 12},
{component: 'DiffCreditDebit', size: 12},
{component: 'CategoriesStats', size: 12},
{component: 'MonthThresholds', size: 12},
{ component: 'CapitalChart', size: 8 },
{ component: 'SavingAccountsChart', size: 4 },
{ component: 'DistributionChart', size: 12 },
{ component: 'DiffCreditDebitChart', size: 12 },
{ component: 'CategoriesChart', size: 12 },
{ component: 'MonthThresholdsTable', size: 12 },
]
const account = ref(getStorage(`dashboard:account`))
@ -149,12 +149,7 @@ const monthThresholdsData = () => {
return _monthThresholdsData.value
}
_monthThresholdsData.value = monthThresholds(
data.value,
categories.value,
dateFrom.value,
dateTo.value,
)
_monthThresholdsData.value = monthThresholds(data.value, categories.value, dateFrom.value, dateTo.value)
return _monthThresholdsData.value
}
@ -189,6 +184,11 @@ window.addEventListener('resize', () => {
}, 500)
})
const setConfigSize = (key, value) => {
config.value[key].size = value
updateConfig()
}
const updateConfig = () => {
saveStorage(`dashboard:config`, config.value)
refresh()
@ -211,9 +211,7 @@ const refresh = () => {
fetch(`/api/transaction?${new URLSearchParams(query)}`, createRequestOptions())
.then((response) => response.json())
.then((value) => {
data.value = value.rows.filter(
(row) => !row.category || row.category.ignore_transactions === false,
)
data.value = value.rows.filter((row) => !row.category || row.category.ignore_transactions === false)
isLoading.value = false
})
}
@ -227,7 +225,11 @@ onMounted(() => {
.then((response) => response.json())
.then((data) => {
categories.value = data.rows
categories.value.push({id: -1, label: 'Sans catégorie', color: '#cccccc'})
categories.value.push({
id: -1,
label: 'Sans catégorie',
color: '#cccccc',
})
})
fetch('/api/bank_account', createRequestOptions())

View file

@ -3,7 +3,7 @@
v-if="tree !== null && fileId === null"
class="w-100"
>
<Header title="Fichiers">
<CrudHeader title="Fichiers">
<template #menu>
<BButtonToolbar key-nav>
<BButton
@ -19,7 +19,7 @@
>
</BButtonToolbar>
</template>
</Header>
</CrudHeader>
<div
v-if="!tree.is_root"
@ -85,10 +85,15 @@
</div>
<div
v-else
class="text-secondary p-3 border-bottom"
class="p-3 border-bottom cursor"
>
<i class="fa-regular fa-file"></i>
{{ item.name }}
<a
:href="`/api/filemanager/file/${item.id}/${item.name}`"
target="_blank"
>
<i class="fa-regular fa-file"></i>
{{ item.name }}
</a>
</div>
</div>
</div>
@ -105,6 +110,7 @@
</div>
<iframe
id="collabora"
title="Fichier"
:src="'/collabora/' + fileId + '?extension=' + fileExtension"
></iframe>
</div>
@ -118,8 +124,8 @@
<BAlert
:model-value="form.error !== null"
variant="danger"
v-text="form.error"
></BAlert>
>{{ form.error }}</BAlert
>
<BForm @submit="doSave">
<BFormGroup
v-for="(field, key) in form.fields"
@ -174,21 +180,10 @@
</template>
<script setup>
import {ref, onMounted} from 'vue'
import Header from './../components/crud/Header.vue'
import {
BModal,
BButton,
BForm,
BFormGroup,
BFormInput,
BAlert,
BFormSelect,
BFormFile,
BDropdown,
BDropdownItem,
} from 'bootstrap-vue-next'
import {createRequestOptions} from '../lib/request'
import { ref, onMounted } from 'vue'
import CrudHeader from './../components/crud/CrudHeader.vue'
import { BModal, BButton, BForm, BFormGroup, BFormInput, BAlert, BFormSelect, BFormFile, BDropdown, BDropdownItem } from 'bootstrap-vue-next'
import { createRequestOptions } from '../lib/request'
let formFile = false
const tree = ref(null)
@ -214,14 +209,11 @@ const withParents = (tree) => {
}
const isEditable = (item) => {
return [
'application/vnd.oasis.opendocument.spreadsheet',
'text/csv',
].includes(item.type)
return ['application/vnd.oasis.opendocument.spreadsheet', 'text/csv'].includes(item.type)
}
const doAddFile = () => {
const data = {file: null}
const data = { file: null }
formFile = true
form.value = {
@ -245,7 +237,7 @@ const doAddFile = () => {
}
const doAddDirectory = () => {
const data = {directory: null, path: tree.value.id}
const data = { directory: null, path: tree.value.id }
formFile = false
form.value = {
@ -272,10 +264,13 @@ const doDelete = (item) => {
return
}
fetch(`/api/filemanager/file/${item.id}`, createRequestOptions({
method: 'DELETE',
})).then(() => {
refresh({current: tree.value.id})
fetch(
`/api/filemanager/file/${item.id}`,
createRequestOptions({
method: 'DELETE',
}),
).then(() => {
refresh({ current: tree.value.id })
})
}
@ -287,13 +282,16 @@ const doSave = (e) => {
payload.append('file', form.value.data.file)
payload.append('path', tree.value.id)
fetch(`/api/filemanager/file`, createRequestOptions({
method: form.value.method,
headers: {
Accept: 'application/json',
},
body: payload,
}))
fetch(
`/api/filemanager/file`,
createRequestOptions({
method: form.value.method,
headers: {
Accept: 'application/json',
},
body: payload,
}),
)
.then((response) => {
return response.json()
})
@ -303,21 +301,24 @@ const doSave = (e) => {
} else {
form.value = null
formShow.value = false
refresh({current: tree.value.id})
refresh({ current: tree.value.id })
}
})
.catch((err) => {
form.value.error = `Une erreur s'est produite : ${err}`
})
} else {
fetch(`/api/filemanager/directory`, createRequestOptions({
method: form.value.method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(form.value.data),
}))
fetch(
`/api/filemanager/directory`,
createRequestOptions({
method: form.value.method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(form.value.data),
}),
)
.then((response) => {
return response.json()
})
@ -327,7 +328,7 @@ const doSave = (e) => {
} else {
form.value = null
formShow.value = false
refresh({current: tree.value.id})
refresh({ current: tree.value.id })
}
})
.catch((err) => {
@ -372,3 +373,10 @@ const searchTree = (id, tree) => {
onMounted(() => refresh())
</script>
<style scoped>
a {
text-decoration: none;
color: var(--bs-body-color);
}
</style>

View file

@ -1,369 +0,0 @@
<template>
<BContainer
fluid
class="p-0"
>
<Header title="Comptes épargnes">
<template #menu>
<BButtonToolbar key-nav>
<BButton
variant="primary"
@click="doAdd"
>Ajouter</BButton
>
</BButtonToolbar>
</template>
</Header>
<Pager
v-model:page="page"
v-model:pages="pages"
v-model:limit="limit"
@update="refresh()"
/>
<div class="crud-list">
<BTableSimple
v-if="data !== null"
caption-top
responsive
>
<BThead>
<BTr>
<BTh
v-for="field in fields"
:key="field.key"
:width="field.width"
class="cursor"
:class="field.thClasses"
valign="top"
@click="doSort(field.key)"
>
<SortButton
:current-order="order"
:current-sort="sort"
:order="field.orderKey ?? field.key"
:label="field.label ?? field.renderLabel(data.rows)"
/>
</BTh>
</BTr>
</BThead>
<BTbody>
<BTr
v-for="(row, key) in data.rows"
:key="key"
>
<BTd
v-for="field in fields"
:key="field.key"
class="cursor"
:class="field.tdClasses"
@click="doEdit(row)"
>
<span v-if="field.key">
<span
v-if="field.render"
v-html="field.render(row)"
></span>
<span
v-else
v-text="row[field.key]"
></span>
</span>
<span
v-else
v-html="field.render(row)"
></span>
</BTd>
</BTr>
</BTbody>
</BTableSimple>
</div>
<BModal
v-if="form !== null"
v-model="formShow"
:title="form?.label"
footer-class="justify-content-between"
@ok="doSave"
>
<BAlert
:model-value="form.error !== null"
variant="danger"
v-text="form.error"
></BAlert>
<BForm @submit="doSave">
<BFormGroup
v-for="(field, key) in form.fields"
:id="'form-label-' + key"
:key="key"
class="mb-2"
:label="field.label"
:label-for="'form-label-' + key"
:description="field.description"
>
<BFormInput
v-if="(field.widget ?? 'text') === 'text'"
:id="'form-input-' + key"
v-model="form.data[field.key]"
:type="field.type"
:required="field.required"
/>
</BFormGroup>
</BForm>
<template #footer>
<div>
<BButton
v-if="form.data.id"
variant="danger"
@click="doDelete"
>Supprimer</BButton
>
</div>
<div>
<BButton
variant="secondary"
class="me-2"
@click="formShow = false"
>Annuler</BButton
>
<BButton
variant="primary"
@click="doSave"
>OK</BButton
>
</div>
</template>
</BModal>
</BContainer>
</template>
<script setup>
import {
BTbody,
BThead,
BTr,
BTd,
BTh,
BContainer,
BTableSimple,
BModal,
BButton,
BForm,
BFormGroup,
BFormInput,
BAlert,
BButtonToolbar,
} from 'bootstrap-vue-next'
import SortButton from './../components/SortButton.vue'
import Header from './../components/crud/Header.vue'
import Pager from './../components/crud/Pager.vue'
import {ref, onMounted, watch} from 'vue'
import {getStorage, saveStorage} from '../lib/storage'
import {renderDate, renderEuro, renderLabelWithSum} from '../lib/renderers'
import {createRequestOptions} from '../lib/request'
const endpoint = `/api/saving_account`
const order = ref(getStorage(`${endpoint}:order`))
const sort = ref(getStorage(`${endpoint}:sort`))
const page = ref(getStorage(`${endpoint}:page`))
const limit = ref(getStorage(`${endpoint}:limit`))
const data = ref(null)
const pages = ref(null)
const form = ref(null)
const formShow = ref(false)
watch(order, (v) => saveStorage(`${endpoint}:order`, v))
watch(sort, (v) => saveStorage(`${endpoint}:sort`, v))
watch(page, (v) => saveStorage(`${endpoint}:page`, v))
watch(limit, (v) => saveStorage(`${endpoint}:limit`, v))
const refresh = () => {
fetch(
`${endpoint}?${new URLSearchParams({
order: order.value,
sort: sort.value,
page: page.value,
limit: limit.value,
})}`,
createRequestOptions()
)
.then((response) => {
return response.json()
})
.then(function (value) {
data.value = value
order.value = value.order
sort.value = value.sort
page.value = value.page
pages.value = value.total_pages
limit.value = value.limit
})
}
const doEdit = (item) => {
const data = {...item}
form.value = {
action: `${endpoint}/${item.id}`,
method: 'POST',
data: data,
label: data.label,
error: null,
fields: [
{
label: 'Libellé',
type: 'text',
required: true,
key: 'label',
},
{
label: 'Montant bloqué',
type: 'number',
key: 'blocked_amount',
},
{
label: 'Montant débloqué',
type: 'number',
key: 'released_amount',
},
],
}
formShow.value = true
}
const doAdd = () => {
const data = {label: null, released_amount: 0, blocked_amount: 0}
form.value = {
action: `${endpoint}`,
method: 'POST',
data: data,
label: 'Nouveau',
error: null,
fields: [
{
label: 'Libellé',
type: 'text',
required: true,
key: 'label',
},
{
label: 'Montant bloqué',
type: 'number',
key: 'blocked_amount',
},
{
label: 'Montant débloqué',
type: 'number',
key: 'released_amount',
},
],
}
formShow.value = true
}
const doDelete = () => {
if (!confirm('Je confirme la suppression')) {
return
}
fetch(`${endpoint}/${form.value.data.id}`, createRequestOptions({
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})).then(() => {
formShow.value = false
refresh()
})
}
const doSave = (e) => {
e.preventDefault()
const url = form.value.data.id
? `${endpoint}/${form.value.data.id}`
: endpoint
form.value.data.released_amount = parseFloat(form.value.data.released_amount)
form.value.data.blocked_amount = parseFloat(form.value.data.blocked_amount)
fetch(url, createRequestOptions({
method: form.value.method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(form.value.data),
}))
.then((response) => {
return response.json()
})
.then((data) => {
if (data.code === 400) {
form.value.error = data.message
} else {
form.value = null
formShow.value = false
refresh()
}
})
.catch((err) => {
form.value.error = `Une erreur s'est produite : ${err}`
})
}
const doSort = (key) => {
let nextSort = 'asc'
if (order.value === key) {
nextSort = sort.value === 'asc' ? 'desc' : 'asc'
}
order.value = key
sort.value = nextSort
page.value = 1
refresh()
}
const fields = [
{
key: 'label',
label: 'Libellé',
},
{
key: 'blocked_amount',
renderLabel: (rows) => renderLabelWithSum('Bloqué', rows, 'blocked_amount'),
render: (item) => renderEuro(item.blocked_amount),
width: '170px',
thClasses: ['text-end'],
tdClasses: ['text-end'],
},
{
key: 'released_amount',
renderLabel: (rows) =>
renderLabelWithSum('Débloqué', rows, 'released_amount'),
render: (item) => renderEuro(item.released_amount),
width: '170px',
thClasses: ['text-end'],
tdClasses: ['text-end'],
},
{
key: 'updated_at',
label: 'Mise à jour',
render: (item) => renderDate(item.updated_at),
width: '170px',
thClasses: ['text-end'],
tdClasses: ['text-end'],
},
]
onMounted(() => {
refresh()
})
</script>

View file

@ -1,528 +0,0 @@
<template>
<BContainer
fluid
class="p-0"
>
<Header title="Transactions">
<template #menu>
<BButtonToolbar key-nav>
<BButton
variant="secondary"
class="me-2"
@click="filtersShow = true"
>Filtres</BButton
>
<BButton
variant="primary"
@click="doAdd"
>Importer</BButton
>
</BButtonToolbar>
</template>
</Header>
<Pager
v-model:page="page"
v-model:pages="pages"
v-model:limit="limit"
@update="refresh()"
/>
<BModal
v-if="filtersShow"
v-model="filtersShow"
title="Filtres"
hide-footer
>
<Filters
:data="filters"
:fields="filtersFields"
@update="doUpdateFilters"
/>
</BModal>
<div class="crud-list">
<BTableSimple
v-if="data !== null"
caption-top
>
<BThead>
<BTr>
<BTh
v-for="field in fields"
:key="field.key"
:width="field.width"
class="cursor"
:class="field.thClasses"
valign="top"
@click="doSort(field.orderKey ?? field.key)"
>
<SortButton
:current-order="order"
:current-sort="sort"
:order="field.orderKey ?? field.key"
:label="field.label ?? field.renderLabel(data.rows)"
/>
</BTh>
</BTr>
</BThead>
<BTbody>
<BTr
v-for="(row, key) in data.rows"
:key="key"
>
<BTd
v-for="field in fields"
:key="field.key"
:width="field.width"
class="cursor"
:class="field.tdClasses"
@click="doInfo(row)"
>
<span v-if="field.key">
<span
v-if="field.render"
v-html="field.render(row)"
></span>
<span
v-else
v-text="row[field.key]"
></span>
</span>
<span
v-else
v-html="field.render(row)"
></span>
</BTd>
</BTr>
</BTbody>
</BTableSimple>
</div>
<BModal
v-if="info !== null"
v-model="infoShow"
class="modal-lg info"
:title="info.reference"
>
<BTableSimple
responsive
class="w-100"
>
<BTr>
<BTh>Libellé</BTh>
<BTd>{{ info.label }}</BTd>
</BTr>
<BTr>
<BTh>Libellé simplifié</BTh>
<BTd>{{ info.short_label }}</BTd>
</BTr>
<BTr>
<BTh>Référence</BTh>
<BTd>{{ info.reference }}</BTd>
</BTr>
<BTr>
<BTh>Informations</BTh>
<BTd>{{ info.information }}</BTd>
</BTr>
<BTr>
<BTh>Type d'opération</BTh>
<BTd>{{ info.operation_type }}</BTd>
</BTr>
<BTr>
<BTh>Débit</BTh>
<BTd>{{ info.debit }}</BTd>
</BTr>
<BTr>
<BTh>Crédit</BTh>
<BTd>{{ info.credit }}</BTd>
</BTr>
<BTr>
<BTh>Date</BTh>
<BTd>{{ renderDate(info.date) }}</BTd>
</BTr>
<BTr>
<BTh>Comptabilisée le</BTh>
<BTd>{{ renderDate(info.accounted_at) }}</BTd>
</BTr>
<BTr>
<BTh>Catégorie banque</BTh><BTd>{{ info.bank_category }}</BTd>
</BTr>
<BTr>
<BTh>Sous-catégorie banque</BTh>
<BTd>{{ info.bank_sub_category }}</BTd>
</BTr>
<BTr>
<BTh>Catégorie</BTh>
<BTd v-html="renderCategory(info.category)"></BTd>
</BTr>
</BTableSimple>
<template #footer>
<div>
<BButton
variant="secondary"
class="me-2"
@click="infoShow = false"
>Fermer</BButton
>
</div>
</template>
</BModal>
<BModal
v-if="form !== null"
v-model="formShow"
:title="form?.label"
@ok="doSave"
>
<BAlert
:model-value="form.error !== null"
variant="danger"
v-text="form.error"
></BAlert>
<BForm @submit="doSave">
<BFormGroup
v-for="(field, key) in form.fields"
:id="'form-label-' + key"
:key="key"
class="mb-2"
:label="field.label"
:label-for="'form-label-' + key"
:description="field.description"
>
<BFormInput
v-if="(field.widget ?? 'text') === 'text'"
:id="'form-input-' + key"
v-model="form.data[field.key]"
:type="field.type"
:required="field.required"
/>
<BFormFile
v-if="field.widget === 'file'"
:id="'form-input-' + key"
v-model="form.data[field.key]"
:required="field.required"
/>
<BFormSelect
v-if="field.widget === 'select'"
:id="'form-input-' + key"
v-model="form.data[field.key]"
:options="field.options"
:required="field.required"
/>
</BFormGroup>
</BForm>
<template #footer>
<div></div>
<div>
<BButton
variant="secondary"
class="me-2"
@click="formShow = false"
>Annuler</BButton
>
<BButton
variant="primary"
@click="doSave"
>OK</BButton
>
</div>
</template>
</BModal>
</BContainer>
</template>
<script setup>
import {
BTbody,
BThead,
BTr,
BTd,
BTh,
BContainer,
BTableSimple,
BModal,
BButton,
BForm,
BFormGroup,
BFormInput,
BAlert,
BButtonToolbar,
BFormSelect,
BFormFile,
} from 'bootstrap-vue-next'
import SortButton from './../components/SortButton.vue'
import Filters from './../components/Filters.vue'
import Header from './../components/crud/Header.vue'
import Pager from './../components/crud/Pager.vue'
import {ref, onMounted, watch} from 'vue'
import {getStorage, saveStorage} from '../lib/storage'
import {
renderDate,
renderCategory,
renderBankAccount,
renderEuro,
renderLabelWithSum,
} from '../lib/renderers'
import {queryFilters, appendRequestQueryFilters} from '../lib/filter'
import {useRoute} from 'vue-router'
import {createRequestOptions} from '../lib/request'
const endpoint = `/api/transaction`
const order = ref(getStorage(`${endpoint}:order`))
const sort = ref(getStorage(`${endpoint}:sort`))
const page = ref(getStorage(`${endpoint}:page`))
const limit = ref(getStorage(`${endpoint}:limit`))
const data = ref(null)
const pages = ref(null)
const form = ref(null)
const formShow = ref(false)
const infoShow = ref(false)
const info = ref(null)
const filters = ref(getStorage(`${endpoint}:filters`) ?? [])
const filtersShow = ref(false)
let route = null
watch(order, (v) => saveStorage(`${endpoint}:order`, v, 'order'))
watch(sort, (v) => saveStorage(`${endpoint}:sort`, v, 'sort'))
watch(page, (v) => saveStorage(`${endpoint}:page`, v, 'page'))
watch(limit, (v) => saveStorage(`${endpoint}:limit`, v, 'limit'))
watch(filters, (v) => saveStorage(`${endpoint}:filters`, v, 'filters'))
const doInfo = (item) => {
info.value = item
infoShow.value = true
}
const doUpdateFilters = (a) => {
saveStorage(`${endpoint}:filters`, a, 'filters')
filters.value = a
refresh()
}
const refresh = () => {
let query = {
...queryFilters(filters.value),
order: order.value,
sort: sort.value,
page: page.value,
limit: limit.value,
}
query = appendRequestQueryFilters(query, route)
fetch(`${endpoint}?${new URLSearchParams(query)}`, createRequestOptions())
.then((response) => {
return response.json()
})
.then(function (value) {
data.value = value
order.value = value.order
sort.value = value.sort
page.value = value.page
pages.value = value.total_pages
limit.value = value.limit
})
}
const doAdd = () => {
const data = {category_id: null, file: null, format: 'caisse_epargne'}
fetch(`/api/bank_account?order=label`, createRequestOptions())
.then((response) => {
return response.json()
})
.then((accounts) => {
let options = []
accounts.rows.forEach((item) => {
options.push({
value: item.id,
text: item.label,
})
})
form.value = {
action: `${endpoint}`,
method: 'POST',
data: data,
label: 'Importer',
error: null,
fields: [
{
label: 'Compte bancaire',
widget: 'select',
options: options,
required: true,
key: 'bank_account_id',
},
{
label: 'Format de fichier',
key: 'format',
widget: 'select',
options: [
{value: 'caisse_epargne', text: "Caisse d'épargne"},
{value: 'revolut', text: 'Revolut'},
],
},
{
label: 'Fichier',
description: `Fichier CSV des opérations`,
widget: 'file',
required: true,
key: 'file',
},
],
}
formShow.value = true
})
}
const doSave = (e) => {
e.preventDefault()
const payload = new FormData()
payload.append('bank_account_id', form.value.data.bank_account_id)
payload.append('file', form.value.data.file)
payload.append('format', form.value.data.format)
fetch(`/api/transactions`, createRequestOptions({
method: form.value.method,
headers: {
Accept: 'application/json',
},
body: payload,
}))
.then((response) => {
return response.json()
})
.then((data) => {
if (data.code === 400) {
form.value.error = data.message
} else {
form.value = null
formShow.value = false
refresh()
}
})
.catch((err) => {
form.value.error = `Une erreur s'est produite : ${err}`
})
}
const doSort = (key) => {
let nextSort = 'asc'
if (order.value === key) {
nextSort = sort.value === 'asc' ? 'desc' : 'asc'
}
order.value = key
sort.value = nextSort
page.value = 1
refresh()
}
const filtersFields = ref([
{key: 'label', type: 'string', label: 'Libellé'},
{key: 'debit', type: 'number', label: 'Débit'},
{key: 'credit', type: 'number', label: 'Crédit'},
{key: 'date', type: 'date', label: 'Date'},
{key: 'operation_type', type: 'string', label: 'Type'},
{key: 'category_id', type: 'select', label: 'Catégorie', options: []},
{key: 'bank_account_id', type: 'select', label: 'Compte', options: []},
])
const fields = [
{
key: 'date',
label: 'Date',
width: '90px',
render: (item) => renderDate(item.date),
},
{
key: 'label',
label: 'Libellé',
},
{
key: 'operation_type',
width: '200px',
label: 'Type',
},
{
key: 'category',
label: 'Catégorie',
orderKey: 'category_id',
width: '400px',
render: (item) => renderCategory(item.category),
},
{
key: 'bank_account',
label: 'Compte',
width: '200px',
orderKey: 'bank_account_id',
render: (item) => renderBankAccount(item.bank_account),
},
{
key: 'debit',
renderLabel: (rows) => renderLabelWithSum('Débit', rows, 'debit'),
width: '120px',
thClasses: ['text-end'],
tdClasses: ['text-end'],
render: (item) => renderEuro(item.debit),
},
{
key: 'credit',
renderLabel: (rows) => renderLabelWithSum('Crédit', rows, 'credit'),
width: '120px',
thClasses: ['text-end'],
tdClasses: ['text-end'],
render: (item) => renderEuro(item.credit),
},
]
onMounted(() => {
route = useRoute()
refresh()
fetch('/api/category', createRequestOptions())
.then((response) => response.json())
.then((data) => {
filtersFields.value.forEach((value, key) => {
if (value.key === 'category_id') {
data.rows.forEach((item) => {
filtersFields.value[key].options.push({
value: item.id,
text: item.label,
})
})
}
})
})
fetch('/api/bank_account', createRequestOptions())
.then((response) => response.json())
.then((data) => {
filtersFields.value.forEach((value, key) => {
if (value.key === 'bank_account_id') {
data.rows.forEach((item) => {
filtersFields.value[key].options.push({
value: item.id,
text: item.label,
})
})
}
})
})
})
</script>
<style scoped>
.info th,
.info td {
padding-bottom: 10px;
}
</style>

View file

@ -1,352 +0,0 @@
<template>
<BContainer
fluid
class="p-0"
>
<Header title="Utilisateurs">
<template #menu>
<BButtonToolbar key-nav>
<BButton
variant="primary"
@click="doAdd"
>Ajouter</BButton
>
</BButtonToolbar>
</template>
</Header>
<Pager
v-model:page="page"
v-model:pages="pages"
v-model:limit="limit"
@update="refresh()"
/>
<div class="crud-list">
<BTableSimple
v-if="data !== null"
caption-top
responsive
>
<BThead>
<BTr>
<BTh
v-for="field in fields"
:key="field.key"
:width="field.width"
class="cursor"
:class="field.classes"
@click="doSort(field.key)"
>
<SortButton
:current-order="order"
:current-sort="sort"
:order="field.key"
:label="field.label"
/>
</BTh>
</BTr>
</BThead>
<BTbody>
<BTr
v-for="(row, key) in data.rows"
:key="key"
>
<BTd
v-for="field in fields"
:key="field.key"
class="cursor"
@click="doEdit(row)"
>
<span v-if="field.key">
<span
v-if="field.render"
v-html="field.render(row)"
></span>
<span
v-else
v-text="row[field.key]"
></span>
</span>
<span
v-else
v-html="field.render(row)"
></span>
</BTd>
</BTr>
</BTbody>
</BTableSimple>
</div>
<BModal
v-if="form !== null"
v-model="formShow"
:title="form?.label"
@ok="doSave"
>
<BAlert
:model-value="form.error !== null"
variant="danger"
v-text="form.error"
></BAlert>
<BForm @submit="doSave">
<BFormGroup
v-for="(field, key) in form.fields"
:id="'form-label-' + key"
:key="key"
class="mb-2"
:label="field.label"
:label-for="'form-label-' + key"
:description="field.description"
>
<BFormInput
v-if="(field.widget ?? 'text') === 'text'"
:id="'form-input-' + key"
v-model="form.data[field.key]"
:type="field.type"
:required="field.required"
/>
</BFormGroup>
</BForm>
<template #footer>
<div>
<BButton
v-if="form.data.id"
variant="danger"
@click="doDelete"
>Supprimer</BButton
>
</div>
<div>
<BButton
variant="secondary"
class="me-2"
@click="formShow = false"
>Annuler</BButton
>
<BButton
variant="primary"
@click="doSave"
>OK</BButton
>
</div>
</template>
</BModal>
</BContainer>
</template>
<script setup>
import {
BTbody,
BThead,
BTr,
BTd,
BTh,
BContainer,
BTableSimple,
BModal,
BButton,
BForm,
BFormGroup,
BFormInput,
BAlert,
BButtonToolbar,
} from 'bootstrap-vue-next'
import SortButton from './../components/SortButton.vue'
import Header from './../components/crud/Header.vue'
import Pager from './../components/crud/Pager.vue'
import {ref, onMounted, watch} from 'vue'
import {getStorage, saveStorage} from '../lib/storage'
import {createRequestOptions} from '../lib/request'
import {renderDateTime} from '../lib/renderers'
const endpoint = `/api/user`
const order = ref(getStorage(`${endpoint}:order`))
const sort = ref(getStorage(`${endpoint}:sort`))
const page = ref(getStorage(`${endpoint}:page`))
const limit = ref(getStorage(`${endpoint}:limit`))
const data = ref(null)
const pages = ref(null)
const form = ref(null)
const formShow = ref(false)
watch(order, (v) => saveStorage(`${endpoint}:order`, v))
watch(sort, (v) => saveStorage(`${endpoint}:sort`, v))
watch(page, (v) => saveStorage(`${endpoint}:page`, v))
watch(limit, (v) => saveStorage(`${endpoint}:limit`, v))
const refresh = () => {
fetch(
`${endpoint}?${new URLSearchParams({
order: order.value,
sort: sort.value,
page: page.value,
limit: limit.value,
})}`,
createRequestOptions()
)
.then((response) => {
return response.json()
})
.then(function (value) {
data.value = value
order.value = value.order
sort.value = value.sort
page.value = value.page
pages.value = value.total_pages
limit.value = value.limit
})
}
const doEdit = (item) => {
const data = {...item}
form.value = {
action: `${endpoint}/${item.id}`,
method: 'POST',
data: data,
label: data.display_name,
error: null,
fields: [
{
label: 'Nom',
type: 'text',
required: true,
key: 'display_name',
},
{
label: "Nom d'utilisateur",
type: 'text',
required: true,
key: 'username',
},
{
label: 'Mot de passe',
type: 'password',
required: false,
key: 'password',
},
],
}
formShow.value = true
}
const doAdd = () => {
const data = {display_name: null, username: null, password: null}
form.value = {
action: `${endpoint}`,
method: 'POST',
data: data,
label: 'Nouveau',
error: null,
fields: [
{
label: 'Nom',
type: 'text',
required: true,
key: 'display_name',
},
{
label: "Nom d'utilisateur",
type: 'text',
required: true,
key: 'username',
},
{
label: 'Mot de passe',
type: 'password',
required: false,
key: 'password',
},
],
}
formShow.value = true
}
const doDelete = () => {
if (!confirm('Je confirme la suppression')) {
return
}
fetch(`${endpoint}/${form.value.data.id}`, createRequestOptions({
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})).then(() => {
formShow.value = false
refresh()
})
}
const doSave = (e) => {
e.preventDefault()
const url = form.value.data.id
? `${endpoint}/${form.value.data.id}`
: endpoint
fetch(url, createRequestOptions({
method: form.value.method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(form.value.data),
}))
.then((response) => {
return response.json()
})
.then((data) => {
if (data.code === 400) {
form.value.error = data.message
} else {
form.value = null
formShow.value = false
refresh()
}
})
.catch((err) => {
form.value.error = `Une erreur s'est produite : ${err}`
})
}
const doSort = (key) => {
let nextSort = 'asc'
if (order.value === key) {
nextSort = sort.value === 'asc' ? 'desc' : 'asc'
}
order.value = key
sort.value = nextSort
page.value = 1
refresh()
}
const fields = [
{
key: 'display_name',
label: 'Nom',
width: '30%',
},
{
key: 'username',
label: 'Utilisateur',
},
{
key: 'logged_at',
label: 'Dernière connexion',
render: (item) => renderDateTime(item.logged_at),
},
]
onMounted(() => {
refresh({})
})
</script>

View file

@ -0,0 +1,82 @@
<template>
<BContainer
fluid
class="p-0"
>
<CrudHeader :title="$route.meta.label">
<template #menu>
<div>
<BButton
variant="secondary"
@click="doBack"
>Retour</BButton
>
<BButton
variant="primary"
@click="doSave"
>Enregistrer</BButton
>
</div>
</template>
<template #bottom> </template>
</CrudHeader>
<BForm
v-if="form !== null"
class="p-3"
@submit="doSave"
>
<BAlert
v-if="form?.error"
dismissible
variant="warning"
:model-value="true"
>
<div v-html="form.error"></div>
</BAlert>
<BRow>
<BCol>
<FormView :form="form" />
</BCol>
</BRow>
</BForm>
</BContainer>
</template>
<script setup>
import { BContainer, BButton, BForm, BRow, BCol, BAlert } from 'bootstrap-vue-next'
import CrudHeader from './../../components/crud/CrudHeader.vue'
import FormView from './../../components/crud/FormView.vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { create, createForm } from '../../models/bank_account'
import { requestErrorBuilder } from '../../lib/request'
const router = useRouter()
const form = ref(null)
const doSave = () => {
create(
form.value.data,
(data) => {
if (data.code === 400) {
form.value.error = requestErrorBuilder(data)
} else {
doBack()
}
},
(err) => {
form.value.error = `Une erreur s'est produite : ${err}`
},
)
}
const doBack = () => {
router.replace({ name: 'bank_accounts' })
}
onMounted(() => {
form.value = createForm()
})
</script>

View file

@ -0,0 +1,115 @@
<template>
<div
v-if="data === null"
class="text-center p-5"
>
<p>Chargement...</p>
<BSpinner />
</div>
<BContainer
v-else
fluid
class="p-0"
>
<CrudHeader :title="data.label">
<template #menu>
<div>
<BButton
variant="secondary"
@click="doBack"
>Retour</BButton
>
<BButton
variant="primary"
@click="doSave"
>Enregistrer</BButton
>
<BButton
variant="danger"
@click="doDelete"
>Supprimer</BButton
>
</div>
</template>
<template #bottom> </template>
</CrudHeader>
<BForm
v-if="form !== null"
@submit="doSave"
class="p-3"
>
<BAlert
v-if="form?.error"
dismissible
variant="warning"
:model-value="true"
>
<div v-html="form.error"></div>
</BAlert>
<BRow>
<BCol>
<FormView :form="form" />
</BCol>
</BRow>
</BForm>
</BContainer>
</template>
<script setup>
import { BContainer, BButton, BForm, BRow, BCol, BAlert } from 'bootstrap-vue-next'
import CrudHeader from './../../components/crud/CrudHeader.vue'
import FormView from './../../components/crud/FormView.vue'
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { update, remove, getOne, createForm } from '../../models/bank_account'
import { requestErrorBuilder } from '../../lib/request'
const route = useRoute()
const router = useRouter()
const data = ref(null)
const form = ref(null)
const id = route.params.id
const loadData = () => {
getOne(
{
id__eq: id,
},
(value) => {
data.value = value
form.value = createForm(value)
},
)
}
const doDelete = () => {
if (!confirm('Je confirme la suppression')) {
return
}
remove(id, doBack, doBack)
}
const doSave = () => {
update(
form.value.data,
(data) => {
if (data.code === 400) {
form.value.error = requestErrorBuilder(data)
} else {
doBack()
}
},
(err) => {
form.value.error = `Une erreur s'est produite : ${err}`
},
)
}
const doBack = () => {
router.replace({ name: 'bank_accounts' })
}
onMounted(loadData)
</script>

View file

@ -0,0 +1,111 @@
<template>
<BContainer
fluid
class="p-0"
>
<CrudHeader :title="$route.meta.label">
<template #menu>
<BButtonToolbar key-nav>
<BButton
variant="primary"
@click="doCreate"
>Ajouter</BButton
>
</BButtonToolbar>
</template>
<template #bottom>
<CrudPager
v-model:page="page"
v-model:pages="pages"
v-model:limit="limit"
@update="refresh()"
/>
</template>
</CrudHeader>
<div
v-if="data"
class="crud-list"
>
<DataList
:rows="data.rows"
:headers="getListFields()"
:sort="sort"
:order="order"
@sort="doSort"
@row-click="doEdit"
/>
</div>
</BContainer>
</template>
<script setup>
import { BContainer, BButton, BButtonToolbar } from 'bootstrap-vue-next'
import CrudHeader from '../../components/crud/CrudHeader.vue'
import CrudPager from '../../components/crud/CrudPager.vue'
import DataList from '../../components/crud/DataList.vue'
import { ref, onMounted, watch } from 'vue'
import { getStorage, saveStorage } from '../../lib/storage'
import { useRouter } from 'vue-router'
import { getList, getListFields } from '../../models/bank_account'
const endpoint = `/api/bank_account`
const order = ref(getStorage(`${endpoint}:order`))
const sort = ref(getStorage(`${endpoint}:sort`))
const page = ref(getStorage(`${endpoint}:page`))
const limit = ref(getStorage(`${endpoint}:limit`))
const data = ref(null)
const pages = ref(null)
const router = useRouter()
watch(order, (v) => saveStorage(`${endpoint}:order`, v))
watch(sort, (v) => saveStorage(`${endpoint}:sort`, v))
watch(page, (v) => saveStorage(`${endpoint}:page`, v))
watch(limit, (v) => saveStorage(`${endpoint}:limit`, v))
const refresh = () => {
getList(
{
order: order.value,
sort: sort.value,
page: page.value,
limit: limit.value,
},
(value) => {
data.value = value
order.value = value.order
sort.value = value.sort
page.value = value.page
pages.value = value.total_pages
limit.value = value.limit
},
)
}
const doEdit = (item) => {
router.replace({ name: 'bank_account_edit', params: { id: item.id } })
}
const doCreate = () => {
router.replace({ name: 'bank_account_create' })
}
const doSort = (key) => {
let nextSort = 'asc'
if (order.value === key) {
nextSort = sort.value === 'asc' ? 'desc' : 'asc'
}
order.value = key
sort.value = nextSort
page.value = 1
refresh()
}
onMounted(() => {
refresh()
})
</script>

View file

@ -0,0 +1,82 @@
<template>
<BContainer
fluid
class="p-0"
>
<CrudHeader :title="$route.meta.label">
<template #menu>
<div>
<BButton
variant="secondary"
@click="doBack"
>Retour</BButton
>
<BButton
variant="primary"
@click="doSave"
>Enregistrer</BButton
>
</div>
</template>
<template #bottom> </template>
</CrudHeader>
<BForm
v-if="form !== null"
class="p-3"
@submit="submit"
>
<BAlert
v-if="form?.error"
dismissible
variant="warning"
:model-value="true"
>
<div v-html="form.error"></div>
</BAlert>
<BRow>
<BCol>
<FormView :form="form" />
</BCol>
</BRow>
</BForm>
</BContainer>
</template>
<script setup>
import { BContainer, BButton, BForm, BRow, BCol, BAlert } from 'bootstrap-vue-next'
import CrudHeader from './../../components/crud/CrudHeader.vue'
import FormView from './../../components/crud/FormView.vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { create, createForm } from '../../models/category'
import { requestErrorBuilder } from '../../lib/request'
const router = useRouter()
const form = ref(null)
const doSave = () => {
create(
form.value.data,
(data) => {
if (data.code === 400) {
form.value.error = requestErrorBuilder(data)
} else {
router.replace({ name: 'category_edit', params: { id: data.id } })
}
},
(err) => {
form.value.error = `Une erreur s'est produite : ${err}`
},
)
}
const doBack = () => {
router.replace({ name: 'categories' })
}
onMounted(() => {
form.value = createForm()
})
</script>

View file

@ -0,0 +1,231 @@
<template>
<div
v-if="data === null"
class="text-center p-5"
>
<p>Chargement...</p>
<BSpinner />
</div>
<BContainer
v-else
fluid
class="p-0"
>
<CrudHeader :title="data.label">
<template #menu>
<div>
<BButton
variant="secondary"
@click="doBack"
>Retour</BButton
>
<BButton
variant="secondary"
@click="doApply"
>
<VueSpinner
v-if="applyInProgress"
size="20"
color="white"
/>
<span v-else>Appliquer&nbsp;les&nbsp;règles</span>
</BButton>
<BButton
variant="secondary"
@click="form.data['rules'] = doAddRule(form.data['rules'])"
>Ajouter règle</BButton
>
<BButton
variant="primary"
@click="doSave"
>Enregistrer</BButton
>
<BButton
variant="danger"
@click="doDelete"
>Supprimer</BButton
>
</div>
</template>
<template #bottom> </template>
</CrudHeader>
<BForm
v-if="form !== null"
@submit="doSave"
class="p-3"
>
<BAlert
v-if="form?.error"
dismissible
variant="warning"
:model-value="true"
>
<div v-html="form.error"></div>
</BAlert>
<BRow>
<BCol
cols="12"
:md="form.data.rules && form.data.rules.length > 0 ? 4 : 12"
>
<FormView
:form="form"
:fields="['label', 'month_threshold', 'ignore_transactions', 'color']"
/>
</BCol>
<BCol
cols="12"
:md="form.data.rules && form.data.rules.length > 0 ? 8 : 12"
>
<div
class="rounded"
:class="{ border: form.data.rules && form.data.rules.length > 0 }"
>
<FormView
:form="form"
:fields="['rules']"
/>
</div>
</BCol>
</BRow>
</BForm>
</BContainer>
</template>
<script setup>
import { VueSpinner } from 'vue3-spinners'
import { BContainer, BButton, BForm, BRow, BCol, BAlert } from 'bootstrap-vue-next'
import CrudHeader from './../../components/crud/CrudHeader.vue'
import FormView from './../../components/crud/FormView.vue'
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { update, remove, getOne, createForm } from '../../models/category'
import { updateCategories, getOne as getTransactionOne } from '../../models/transaction'
import { requestErrorBuilder } from '../../lib/request'
const route = useRoute()
const router = useRouter()
const data = ref(null)
const form = ref(null)
const formShow = ref(false)
const applyInProgress = ref(false)
const id = route.params.id
const transactionId = route.params.transaction
let addTransaction = transactionId !== ''
const loadData = () => {
getOne(
{
id__eq: id,
},
(value) => {
data.value = value
form.value = createForm(value)
checkTransaction()
},
)
}
const checkTransaction = () => {
if (!addTransaction) {
return
}
getTransactionOne(
{
id__eq: transactionId,
},
(transaction) => {
doAddRule(form.value.data['rules'], {
contain: transaction.reference,
amount: null,
match: null,
bank_category: null,
date_from: null,
date_to: null,
})
},
)
}
const doApply = () => {
applyInProgress.value = true
const callback = () => {
applyInProgress.value = false
}
updateCategories(callback, callback)
}
const doAddRule = (item, rule) => {
if (!rule) {
rule = {
contain: null,
match: null,
bank_category: null,
amount: null,
date_from: null,
date_to: null,
}
}
if (item == null) {
item = []
}
item.push(rule)
window.setTimeout(() => {
const container = document.querySelector('#rules > div')
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' })
}, 300)
return item
}
const doDelete = () => {
if (!confirm('Je confirme la suppression')) {
return
}
remove(id, doBack, doBack)
}
const doSave = () => {
update(
form.value.data,
(data) => {
if (data.code === 400) {
form.value.error = requestErrorBuilder(data)
} else {
form.value = null
addTransaction = false
formShow.value = false
loadData()
}
},
(err) => {
form.value.error = `Une erreur s'est produite : ${err}`
},
)
}
const doBack = () => {
if (transactionId) {
router.replace({ name: 'transactions' })
} else {
router.replace({ name: 'categories' })
}
}
onMounted(loadData)
</script>
<style>
#rules > div {
max-height: calc(100vh - 140px);
overflow: auto;
}
</style>

View file

@ -0,0 +1,134 @@
<template>
<BContainer
fluid
class="p-0"
>
<CrudHeader :title="$route.meta.label">
<template #menu>
<BButtonToolbar key-nav>
<BButton
variant="secondary"
class="me-2"
@click="doApply"
>
<VueSpinner
v-if="applyInProgress"
size="20"
color="white"
/>
<span v-else>Appliquer&nbsp;les&nbsp;règles</span>
</BButton>
<BButton
variant="primary"
@click="doCreate"
>Ajouter</BButton
>
</BButtonToolbar>
</template>
<template #bottom>
<CrudPager
v-model:page="page"
v-model:pages="pages"
v-model:limit="limit"
@update="refresh()"
/>
</template>
</CrudHeader>
<div
v-if="data"
class="crud-list"
>
<DataList
:rows="data.rows"
:headers="getListFields()"
:sort="sort"
:order="order"
@sort="doSort"
@row-click="doEdit"
/>
</div>
</BContainer>
</template>
<script setup>
import { BContainer, BButton, BButtonToolbar } from 'bootstrap-vue-next'
import { VueSpinner } from 'vue3-spinners'
import CrudHeader from '../../components/crud/CrudHeader.vue'
import CrudPager from '../../components/crud/CrudPager.vue'
import DataList from '../../components/crud/DataList.vue'
import { ref, onMounted, watch } from 'vue'
import { getStorage, saveStorage } from '../../lib/storage'
import { useRouter } from 'vue-router'
import { getList, getListFields } from '../../models/category'
import { updateCategories } from '../../models/transaction'
const endpoint = `/api/category`
const order = ref(getStorage(`${endpoint}:order`))
const sort = ref(getStorage(`${endpoint}:sort`))
const page = ref(getStorage(`${endpoint}:page`))
const limit = ref(getStorage(`${endpoint}:limit`))
const data = ref(null)
const pages = ref(null)
const applyInProgress = ref(false)
const router = useRouter()
watch(order, (v) => saveStorage(`${endpoint}:order`, v))
watch(sort, (v) => saveStorage(`${endpoint}:sort`, v))
watch(page, (v) => saveStorage(`${endpoint}:page`, v))
watch(limit, (v) => saveStorage(`${endpoint}:limit`, v))
const doApply = () => {
applyInProgress.value = true
const callback = () => {
applyInProgress.value = false
}
updateCategories(callback, callback)
}
const refresh = () => {
getList(
{
order: order.value,
sort: sort.value,
page: page.value,
limit: limit.value,
},
(value) => {
data.value = value
order.value = value.order
sort.value = value.sort
page.value = value.page
pages.value = value.total_pages
limit.value = value.limit
},
)
}
const doEdit = (item) => {
router.replace({ name: 'category_edit', params: { id: item.id } })
}
const doCreate = () => {
router.replace({ name: 'category_create' })
}
const doSort = (key) => {
let nextSort = 'asc'
if (order.value === key) {
nextSort = sort.value === 'asc' ? 'desc' : 'asc'
}
order.value = key
sort.value = nextSort
page.value = 1
refresh()
}
onMounted(refresh)
</script>

View file

@ -0,0 +1,82 @@
<template>
<BContainer
fluid
class="p-0"
>
<CrudHeader :title="$route.meta.label">
<template #menu>
<div>
<BButton
variant="secondary"
@click="doBack"
>Retour</BButton
>
<BButton
variant="primary"
@click="doSave"
>Enregistrer</BButton
>
</div>
</template>
<template #bottom> </template>
</CrudHeader>
<BForm
v-if="form !== null"
class="p-3"
@submit="submit"
>
<BAlert
v-if="form?.error"
dismissible
variant="warning"
:model-value="true"
>
<div v-html="form.error"></div>
</BAlert>
<BRow>
<BCol>
<FormView :form="form" />
</BCol>
</BRow>
</BForm>
</BContainer>
</template>
<script setup>
import { BContainer, BButton, BForm, BRow, BCol, BAlert } from 'bootstrap-vue-next'
import CrudHeader from './../../components/crud/CrudHeader.vue'
import FormView from './../../components/crud/FormView.vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { create, createForm } from '../../models/saving_account'
import { requestErrorBuilder } from '../../lib/request'
const router = useRouter()
const form = ref(null)
const doSave = () => {
create(
form.value.data,
(data) => {
if (data.code === 400) {
form.value.error = requestErrorBuilder(data)
} else {
doBack()
}
},
(err) => {
form.value.error = `Une erreur s'est produite : ${err}`
},
)
}
const doBack = () => {
router.replace({ name: 'saving_accounts' })
}
onMounted(() => {
form.value = createForm()
})
</script>

View file

@ -0,0 +1,116 @@
<template>
<div
v-if="data === null"
class="text-center p-5"
>
<p>Chargement...</p>
<BSpinner />
</div>
<BContainer
v-else
fluid
class="p-0"
>
<CrudHeader :title="data.label">
<template #menu>
<div>
<BButton
variant="secondary"
@click="doBack"
>Retour</BButton
>
<BButton
variant="primary"
@click="doSave"
>Enregistrer</BButton
>
<BButton
variant="danger"
@click="doDelete"
>Supprimer</BButton
>
</div>
</template>
<template #bottom> </template>
</CrudHeader>
<BForm
v-if="form !== null"
@submit="doSave"
class="p-3"
>
<BAlert
v-if="form?.error"
dismissible
variant="warning"
:model-value="true"
>
<div v-html="form.error"></div>
</BAlert>
<BRow>
<BCol>
<FormView :form="form" />
</BCol>
</BRow>
</BForm>
</BContainer>
</template>
<script setup>
import { BContainer, BButton, BForm, BRow, BCol, BAlert } from 'bootstrap-vue-next'
import CrudHeader from './../../components/crud/CrudHeader.vue'
import FormView from './../../components/crud/FormView.vue'
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { update, remove, getOne, createForm } from '../../models/saving_account'
import { requestErrorBuilder } from '../../lib/request'
const route = useRoute()
const router = useRouter()
const data = ref(null)
const form = ref(null)
const id = route.params.id
const loadData = () => {
getOne(
{
id__eq: id,
},
(value) => {
data.value = value
form.value = createForm(value)
},
)
}
const doDelete = () => {
if (!confirm('Je confirme la suppression')) {
return
}
remove(id, doBack, doBack)
}
const doSave = () => {
update(
form.value.data,
(data) => {
if (data.code === 400) {
form.value.error = requestErrorBuilder(data)
} else {
doBack()
}
},
(err) => {
form.value.error = `Une erreur s'est produite : ${err}`
},
)
}
const doBack = () => {
router.replace({ name: 'saving_accounts' })
}
onMounted(loadData)
</script>

View file

@ -0,0 +1,109 @@
<template>
<BContainer
fluid
class="p-0"
>
<CrudHeader :title="$route.meta.label">
<template #menu>
<BButtonToolbar key-nav>
<BButton
variant="primary"
@click="doCreate"
>Ajouter</BButton
>
</BButtonToolbar>
</template>
<template #bottom>
<CrudPager
v-model:page="page"
v-model:pages="pages"
v-model:limit="limit"
@update="refresh()"
/>
</template>
</CrudHeader>
<div
v-if="data"
class="crud-list"
>
<DataList
:rows="data.rows"
:headers="getListFields()"
:sort="sort"
:order="order"
@sort="doSort"
@row-click="doEdit"
/>
</div>
</BContainer>
</template>
<script setup>
import { BContainer, BButton, BButtonToolbar } from 'bootstrap-vue-next'
import CrudHeader from '../../components/crud/CrudHeader.vue'
import CrudPager from '../../components/crud/CrudPager.vue'
import DataList from '../../components/crud/DataList.vue'
import { ref, onMounted, watch } from 'vue'
import { getStorage, saveStorage } from '../../lib/storage'
import { useRouter } from 'vue-router'
import { getList, getListFields } from '../../models/saving_account'
const endpoint = `/api/saving_account`
const order = ref(getStorage(`${endpoint}:order`))
const sort = ref(getStorage(`${endpoint}:sort`))
const page = ref(getStorage(`${endpoint}:page`))
const limit = ref(getStorage(`${endpoint}:limit`))
const data = ref(null)
const pages = ref(null)
const router = useRouter()
watch(order, (v) => saveStorage(`${endpoint}:order`, v))
watch(sort, (v) => saveStorage(`${endpoint}:sort`, v))
watch(page, (v) => saveStorage(`${endpoint}:page`, v))
watch(limit, (v) => saveStorage(`${endpoint}:limit`, v))
const refresh = () => {
getList(
{
order: order.value,
sort: sort.value,
page: page.value,
limit: limit.value,
},
(value) => {
data.value = value
order.value = value.order
sort.value = value.sort
page.value = value.page
pages.value = value.total_pages
limit.value = value.limit
},
)
}
const doEdit = (item) => {
router.replace({ name: 'saving_account_edit', params: { id: item.id } })
}
const doCreate = () => {
router.replace({ name: 'saving_account_create' })
}
const doSort = (key) => {
let nextSort = 'asc'
if (order.value === key) {
nextSort = sort.value === 'asc' ? 'desc' : 'asc'
}
order.value = key
sort.value = nextSort
page.value = 1
refresh()
}
onMounted(refresh)
</script>

View file

@ -0,0 +1,232 @@
<template>
<BContainer
fluid
class="p-0"
>
<RouterView />
<CrudHeader :title="$route.meta.label">
<template #menu>
<BButtonToolbar key-nav>
<BButton
variant="secondary"
class="me-2"
@click="filtersShow = true"
>Filtres</BButton
>
<BButton
variant="primary"
@click="doAdd"
>Importer</BButton
>
</BButtonToolbar>
</template>
<template #bottom>
<CrudPager
v-model:page="page"
v-model:pages="pages"
v-model:limit="limit"
@update="refresh()"
/>
</template>
</CrudHeader>
<div
v-if="data"
class="crud-list"
>
<DataList
:rows="data.rows"
:headers="getListFields()"
:sort="sort"
:order="order"
@sort="doSort"
@row-click="doShow"
/>
</div>
<BModal
v-if="form !== null"
v-model="formShow"
:title="form?.label"
@ok="doSave"
>
<BAlert
:model-value="form.error !== null"
variant="danger"
>
<div v-html="form.error"></div>
</BAlert>
<BForm @submit="doSave">
<FormView :form="form" />
</BForm>
<template #footer>
<div></div>
<div>
<BButton
variant="secondary"
class="me-2"
@click="formShow = false"
>Annuler</BButton
>
<BButton
variant="primary"
@click="doSave"
>OK</BButton
>
</div>
</template>
</BModal>
<BModal
v-if="filtersShow"
v-model="filtersShow"
title="Filtres"
hide-footer
>
<CrudFilter
:data="filters"
:fields="filtersFields"
@update="doUpdateFilters"
/>
</BModal>
</BContainer>
</template>
<script setup>
import { BContainer, BModal, BButton, BForm, BAlert, BButtonToolbar } from 'bootstrap-vue-next'
import CrudHeader from '../../components/crud/CrudHeader.vue'
import CrudFilter from '../../components/CrudFilter.vue'
import CrudPager from '../../components/crud/CrudPager.vue'
import FormView from '../../components/crud/FormView.vue'
import DataList from '../../components/crud/DataList.vue'
import { ref, onMounted, watch } from 'vue'
import { getStorage, saveStorage } from '../../lib/storage'
import { queryFilters, appendRequestQueryFilters } from '../../lib/filter'
import { useRouter, RouterView } from 'vue-router'
import { getList, getListFields, getListFilters, createForm, importFile } from '../../models/transaction'
import { getList as getAccountList } from '../../models/bank_account'
import { getList as getCategoryList } from '../../models/category'
import { requestErrorBuilder } from '../../lib/request'
const endpoint = `/api/transaction`
const order = ref(getStorage(`${endpoint}:order`))
const sort = ref(getStorage(`${endpoint}:sort`))
const page = ref(getStorage(`${endpoint}:page`))
const limit = ref(getStorage(`${endpoint}:limit`))
const data = ref(null)
const pages = ref(null)
const form = ref(null)
const formShow = ref(false)
const filters = ref(getStorage(`${endpoint}:filters`) ?? [])
const filtersShow = ref(false)
const router = useRouter()
watch(order, (v) => saveStorage(`${endpoint}:order`, v))
watch(sort, (v) => saveStorage(`${endpoint}:sort`, v))
watch(page, (v) => saveStorage(`${endpoint}:page`, v))
watch(limit, (v) => saveStorage(`${endpoint}:limit`, v))
const refresh = () => {
let query = {
...queryFilters(filters.value),
order: order.value,
sort: sort.value,
page: page.value,
limit: limit.value,
}
query = appendRequestQueryFilters(query, endpoint)
getList(query, (value) => {
data.value = value
order.value = value.order
sort.value = value.sort
page.value = value.page
pages.value = value.total_pages
limit.value = value.limit
})
}
const doShow = (item) => {
router.replace({ name: 'transaction_show', params: { id: item.id } })
}
const doUpdateFilters = (a) => {
saveStorage(`${endpoint}:filters`, a, 'filters')
filters.value = a
refresh()
}
const doSort = (key) => {
let nextSort = 'asc'
if (order.value === key) {
nextSort = sort.value === 'asc' ? 'desc' : 'asc'
}
order.value = key
sort.value = nextSort
page.value = 1
refresh()
}
const doAdd = () => {
getAccountList({}, (accounts) => {
form.value = createForm(accounts.rows)
formShow.value = true
})
}
const doSave = () => {
importFile(
form.value.data,
(data) => {
if (data.code === 400) {
form.value.error = requestErrorBuilder(data)
} else {
form.value = null
formShow.value = false
refresh()
}
},
(err) => {
form.value.error = `Une erreur s'est produite : ${err}`
},
)
}
const filtersFields = ref(getListFilters())
onMounted(() => {
refresh()
getCategoryList({}, (data) => {
filtersFields.value.forEach((value, key) => {
if (value.key === 'category_id') {
data.rows.forEach((item) => {
filtersFields.value[key].options.push({
value: item.id,
text: item.label,
})
})
}
})
})
getAccountList({}, (data) => {
filtersFields.value.forEach((value, key) => {
if (value.key === 'bank_account_id') {
data.rows.forEach((item) => {
filtersFields.value[key].options.push({
value: item.id,
text: item.label,
})
})
}
})
})
})
</script>

View file

@ -0,0 +1,158 @@
<template>
<div v-if="data">
<BModal
id="transaction"
v-model="modal"
scrollable
ok-only
size="lg"
:title="data.label"
@hide="onClose"
>
<BTableSimple
v-if="data"
responsive
class="table"
>
<BTr>
<BTh width="30%">Libellé</BTh>
<BTd>{{ data.label }}</BTd>
</BTr>
<BTr>
<BTh>Libellé simplifié</BTh>
<BTd>{{ data.short_label }}</BTd>
</BTr>
<BTr>
<BTh>Référence</BTh>
<BTd>{{ data.reference }}</BTd>
</BTr>
<BTr>
<BTh>Informations</BTh>
<BTd>{{ data.transactionrmation }}</BTd>
</BTr>
<BTr>
<BTh>Type d'opération</BTh>
<BTd>{{ data.operation_type }}</BTd>
</BTr>
<BTr>
<BTh>Débit</BTh>
<BTd>{{ data.debit }}</BTd>
</BTr>
<BTr>
<BTh>Crédit</BTh>
<BTd>{{ data.credit }}</BTd>
</BTr>
<BTr>
<BTh>Date</BTh>
<BTd>{{ renderDate(data.date) }}</BTd>
</BTr>
<BTr>
<BTh>Comptabilisée le</BTh>
<BTd>{{ renderDate(data.accounted_at) }}</BTd>
</BTr>
<BTr>
<BTh>Catégorie banque</BTh>
<BTd>{{ data.bank_category }}</BTd>
</BTr>
<BTr>
<BTh>Sous-catégorie banque</BTh>
<BTd>{{ data.bank_sub_category }}</BTd>
</BTr>
<BTr>
<BTh class="border-0">Catégorie</BTh>
<BTd class="border-0">
<!-- eslint-disable vue/no-v-html -->
<span
v-if="data.category"
class="p-0"
v-html="renderCategory(data.category)"
></span>
<BRow
v-else
class="m-0 p-0"
>
<BCol class="ps-0">
<BFormSelect
v-model="category"
:options="categories"
/>
</BCol>
<BCol>
<BButton
v-if="category"
variant="primary"
@click="createRule"
>Créer règle</BButton
>
</BCol>
</BRow>
</BTd>
</BTr>
</BTableSimple>
</BModal>
</div>
</template>
<script setup>
import { BTableSimple, BTr, BTh, BTd, BModal, BFormSelect, BButton, BRow, BCol } from 'bootstrap-vue-next'
import { renderDate, renderCategory } from '../../lib/renderers'
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getOne } from '../../models/transaction'
import { getList as getCategoryList } from '../../models/category'
const data = ref(null)
const modal = ref(true)
const router = useRouter()
const categories = ref([])
const category = ref(null)
const props = defineProps({
id: {
type: Number,
required: true,
},
})
const id = ref(props.id)
watch(
() => props.id,
(newValue) => {
id.value = newValue
loadData()
},
)
const loadData = () => {
getOne(
{
id__eq: id.value,
},
(value) => {
data.value = value
modal.value = true
},
)
getCategoryList({}, (data) => {
categories.value = []
data.rows.forEach((row) => {
categories.value.push({
value: row.id,
text: row.label,
})
})
})
}
const onClose = () => {
router.replace({ name: 'transactions' })
}
const createRule = () => {
router.replace({ name: 'category_edit', params: { id: category.value, transaction: data.value.id } })
}
onMounted(loadData)
</script>

View file

@ -0,0 +1,82 @@
<template>
<BContainer
fluid
class="p-0"
>
<CrudHeader :title="$route.meta.label">
<template #menu>
<div>
<BButton
variant="secondary"
@click="doBack"
>Retour</BButton
>
<BButton
variant="primary"
@click="doSave"
>Enregistrer</BButton
>
</div>
</template>
<template #bottom> </template>
</CrudHeader>
<BForm
v-if="form !== null"
class="p-3"
@submit="submit"
>
<BAlert
v-if="form?.error"
dismissible
variant="warning"
:model-value="true"
>
<div v-html="form.error"></div>
</BAlert>
<BRow>
<BCol>
<FormView :form="form" />
</BCol>
</BRow>
</BForm>
</BContainer>
</template>
<script setup>
import { BContainer, BButton, BForm, BRow, BCol, BAlert } from 'bootstrap-vue-next'
import CrudHeader from '../../components/crud/CrudHeader.vue'
import FormView from '../../components/crud/FormView.vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { create, createForm } from '../../models/user'
import { requestErrorBuilder } from '../../lib/request'
const router = useRouter()
const parentRouteName = 'users'
const form = ref(null)
const doSave = () => {
create(
form.value.data,
(data) => {
if (data.code === 400) {
form.value.error = requestErrorBuilder(data)
} else {
doBack()
}
},
(err) => {
form.value.error = `Une erreur s'est produite : ${err}`
},
)
}
const doBack = () => {
router.replace({ name: parentRouteName })
}
onMounted(() => {
form.value = createForm()
})
</script>

View file

@ -0,0 +1,116 @@
<template>
<div
v-if="data === null"
class="text-center p-5"
>
<p>Chargement...</p>
<BSpinner />
</div>
<BContainer
v-else
fluid
class="p-0"
>
<CrudHeader :title="data.display_name">
<template #menu>
<div>
<BButton
variant="secondary"
@click="doBack"
>Retour</BButton
>
<BButton
variant="primary"
@click="doSave"
>Enregistrer</BButton
>
<BButton
variant="danger"
@click="doDelete"
>Supprimer</BButton
>
</div>
</template>
<template #bottom> </template>
</CrudHeader>
<BForm
v-if="form !== null"
@submit="doSave"
class="p-3"
>
<BAlert
v-if="form?.error"
dismissible
variant="warning"
:model-value="true"
>
<div v-html="form.error"></div>
</BAlert>
<BRow>
<BCol>
<FormView :form="form" />
</BCol>
</BRow>
</BForm>
</BContainer>
</template>
<script setup>
import { BContainer, BButton, BForm, BRow, BCol, BAlert } from 'bootstrap-vue-next'
import CrudHeader from './../../components/crud/CrudHeader.vue'
import FormView from './../../components/crud/FormView.vue'
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { update, remove, getOne, createForm } from '../../models/user'
import { requestErrorBuilder } from '../../lib/request'
const route = useRoute()
const router = useRouter()
const data = ref(null)
const form = ref(null)
const id = route.params.id
const loadData = () => {
getOne(
{
id__eq: id,
},
(value) => {
data.value = value
form.value = createForm(value)
},
)
}
const doDelete = () => {
if (!confirm('Je confirme la suppression')) {
return
}
remove(id, doBack, doBack)
}
const doSave = () => {
update(
form.value.data,
(data) => {
if (data.code === 400) {
form.value.error = requestErrorBuilder(data)
} else {
doBack()
}
},
(err) => {
form.value.error = `Une erreur s'est produite : ${err}`
},
)
}
const doBack = () => {
router.replace({ name: 'users' })
}
onMounted(loadData)
</script>

View file

@ -0,0 +1,109 @@
<template>
<BContainer
fluid
class="p-0"
>
<CrudHeader :title="$route.meta.label">
<template #menu>
<BButtonToolbar key-nav>
<BButton
variant="primary"
@click="doCreate"
>Ajouter</BButton
>
</BButtonToolbar>
</template>
<template #bottom>
<CrudPager
v-model:page="page"
v-model:pages="pages"
v-model:limit="limit"
@update="refresh()"
/>
</template>
</CrudHeader>
<div
v-if="data"
class="crud-list"
>
<DataList
:rows="data.rows"
:headers="getListFields()"
:sort="sort"
:order="order"
@sort="doSort"
@row-click="doEdit"
/>
</div>
</BContainer>
</template>
<script setup>
import { BContainer, BButton, BButtonToolbar } from 'bootstrap-vue-next'
import CrudHeader from '../../components/crud/CrudHeader.vue'
import CrudPager from '../../components/crud/CrudPager.vue'
import DataList from '../../components/crud/DataList.vue'
import { ref, onMounted, watch } from 'vue'
import { getStorage, saveStorage } from '../../lib/storage'
import { useRouter } from 'vue-router'
import { getList, getListFields } from '../../models/user'
const endpoint = `/api/user`
const order = ref(getStorage(`${endpoint}:order`))
const sort = ref(getStorage(`${endpoint}:sort`))
const page = ref(getStorage(`${endpoint}:page`))
const limit = ref(getStorage(`${endpoint}:limit`))
const data = ref(null)
const pages = ref(null)
const router = useRouter()
watch(order, (v) => saveStorage(`${endpoint}:order`, v))
watch(sort, (v) => saveStorage(`${endpoint}:sort`, v))
watch(page, (v) => saveStorage(`${endpoint}:page`, v))
watch(limit, (v) => saveStorage(`${endpoint}:limit`, v))
const refresh = () => {
getList(
{
order: order.value,
sort: sort.value,
page: page.value,
limit: limit.value,
},
(value) => {
data.value = value
order.value = value.order
sort.value = value.sort
page.value = value.page
pages.value = value.total_pages
limit.value = value.limit
},
)
}
const doEdit = (item) => {
router.replace({ name: 'user_edit', params: { id: item.id } })
}
const doCreate = () => {
router.replace({ name: 'user_create' })
}
const doSort = (key) => {
let nextSort = 'asc'
if (order.value === key) {
nextSort = sort.value === 'asc' ? 'desc' : 'asc'
}
order.value = key
sort.value = nextSort
page.value = 1
refresh()
}
onMounted(refresh)
</script>

View file

@ -1,10 +1,14 @@
$white: #ffffff;
$theme-colors: (
'light': #bfecf8,
'navBg': #f5f6f7,
'navText': #47516b,
'navActiveBg': #3072c7,
'light': #f1c3a2,
'dark': #07070b,
'primary': #293e5b,
'secondary': #4e76ac,
'primary': #3072c7,
'secondary': #a2c7e7,
'header': #d6c52e,
'info': #abca41,
'success': #1eaa62,
'warning': #e5cc1d,
@ -12,36 +16,31 @@ $theme-colors: (
);
$pagination-active-bg: map-get($theme-colors, 'secondary');
$nav-pills-link-active-bg: darken(map-get($theme-colors, 'primary'), 5%);
$nav-pills-link-active-bg: map-get($theme-colors, 'navActiveBg');
@import '~bootstrap/scss/bootstrap';
@import '~bootstrap-vue-next/dist/bootstrap-vue-next.css';
@import '~@fortawesome/fontawesome-free/css/all.css';
@import '@fontsource/ubuntu/300.css';
$light-grey: #e9ecef;
body {
font-family: 'Ubuntu';
}
$light-grey: #f5f6f7;
#app-name {
padding-left: 8px;
}
#login-form {
background: linear-gradient(
-45deg,
map-get($theme-colors, 'primary') 50%,
map-get($theme-colors, 'secondary') 100%
);
background: linear-gradient(-45deg, #f1f1f1 50%, #e1e1e1 100%);
.card {
background: linear-gradient(
-45deg,
darken(map-get($theme-colors, 'primary'), 10%) 50%,
map-get($theme-colors, 'secondary') 100%
);
background: linear-gradient(-45deg, #f1f1f1 50%, #e1e1e1 100%);
}
}
* {
overscroll-behavior: contain !important;
color: #47516b;
}
.cursor {
@ -60,7 +59,11 @@ $nav-size-sm: 50px;
#nav {
width: $nav-size;
background: map-get($theme-colors, 'primary');
background: map-get($theme-colors, 'navBg');
a:not(.active) {
color: map-get($theme-colors, 'navText');
}
i {
width: 20px;
@ -77,7 +80,7 @@ $nav-size-sm: 50px;
box-shadow 0.2s;
&:hover {
box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
box-shadow: 0 0 6px rgba(0, 0, 0, 0.2);
}
canvas {
@ -92,17 +95,30 @@ $nav-size-sm: 50px;
.crud {
&-list {
width: calc(100vw - $nav-size - 30px);
padding: 20px;
background: $light-grey;
border-radius: 10px;
margin-left: 15px;
margin-right: 15px;
width: 100%;
th,
td {
background: $light-grey;
vertical-align: middle;
padding-top: 1rem;
padding-bottom: 1rem;
transition: background 0.5s;
}
tr {
td:first-child,
th:first-child {
padding-left: 1rem;
}
td:last-child,
th:last-child {
padding-right: 1rem;
}
&:hover td {
background: $light-grey;
}
}
thead th {
@ -113,6 +129,27 @@ $nav-size-sm: 50px;
}
}
#transaction {
.modal-body {
padding: 0;
}
.table {
width: 100%;
th,
td {
vertical-align: middle;
padding-top: 0.8rem;
padding-bottom: 0.8rem;
}
th {
padding-left: 0.8rem;
}
}
}
#body {
width: calc(100vw - $nav-size);
max-height: 100vh;
@ -171,7 +208,7 @@ $nav-size-sm: 50px;
.crud {
&-list {
width: calc(100vw - $nav-size-sm - 30px);
width: calc(100vw - $nav-size-sm);
overflow: scroll;
}
}
@ -180,3 +217,9 @@ $nav-size-sm: 50px;
width: calc(100vw - $nav-size-sm);
}
}
.header {
background-image: linear-gradient(45deg, #fafafa 25%, #f5f6f7 25%, #f5f6f7 50%, #fafafa 50%, #fafafa 75%, #f5f6f7 75%, #f5f6f7 100%);
background-size: 56.57px 56.57px;
color: #47516b;
}

4575
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
{
"dependencies": {
"@fontsource/ubuntu": "^5.2.5",
"@fortawesome/fontawesome-free": "^6.6.0",
"@symfony/webpack-encore": "github:symfony/webpack-encore",
"bootstrap": "^5.3.3",
@ -14,7 +15,6 @@
},
"devDependencies": {
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@vitejs/plugin-vue": "^5.0.5",
"babel-loader": "^9.1.3",
"css-loader": "^7.1.2",
"eslint": "^9.19.0",