diff --git a/controllers/contacts.go b/controllers/contacts.go index f5af97f..c49386f 100644 --- a/controllers/contacts.go +++ b/controllers/contacts.go @@ -1,17 +1,18 @@ package controllers import ( - "errors" + "encoding/json" "fmt" "strconv" "strings" "time" "git.readonly.ch/bouzoure/pop-camarades/helpers" + "git.readonly.ch/bouzoure/pop-camarades/helpers/database" "git.readonly.ch/bouzoure/pop-camarades/models" + "github.com/charmbracelet/log" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" - "gorm.io/gorm" ) func Contacts(c *fiber.Ctx) error { @@ -46,131 +47,53 @@ func Contacts(c *fiber.Ctx) error { return err } - filterSection := c.Query("se") - filterArchive := c.Query("a") - filterSearch := c.Query("s") + searchJSON := c.Query("s") + var params database.PeopleSearchParams - sqlFilterSections := allowedSections - sqlFilterAppend := "IS NULL" - if filterArchive == "1" { - sqlFilterAppend = "IS NOT NULL" - sqlFilterSections = allowedSectionsArchived - } - - var filterSectionUint uint - if len(filterSection) > 0 { - filterSectionUint64, err := strconv.ParseUint(filterSection, 10, 0) - if err == nil { - for _, s := range sqlFilterSections { - if s == uint(filterSectionUint64) { - filterSectionUint = uint(filterSectionUint64) - } - } + if len(searchJSON) > 0 { + err = json.Unmarshal([]byte(searchJSON), ¶ms) + if err != nil { + log.Warn(err) + searchJSON = "" } } - if filterSectionUint > 0 { - sqlFilterAppend = fmt.Sprintf( - "%s AND section_id = %d", - sqlFilterAppend, - filterSectionUint, - ) - } - - var sqlFilterSearch string - if len(filterSearch) > 0 { - sqlFilterSearch = fmt.Sprintf( - "%%%s%%", filterSearch, - ) - } - - var count int64 - if len(filterSearch) > 0 { - db.Model(&models.Person{}).Where( - fmt.Sprintf( - "is_contact = ? AND section_id IN ? AND (first_name LIKE ? OR last_name LIKE ?) AND deleted_at %s", - sqlFilterAppend, - ), - true, allowedSections, sqlFilterSearch, sqlFilterSearch, - ).Count(&count) - } else { - db.Model(&models.Person{}).Where( - fmt.Sprintf( - "is_contact = ? AND section_id IN ? AND deleted_at %s", - sqlFilterAppend, - ), - true, allowedSections, - ).Count(&count) - } - - page, _ := strconv.Atoi(c.Query("p")) - pagination := helpers.Paginate(50, int(count), page) - - var people []models.Person - if len(filterSearch) > 0 { - result := db.Unscoped().Offset( - pagination.Offset, - ).Limit( - pagination.PageSize, - ).Order( - "concat(last_name, first_name) collate nocase asc", - ).Preload( - "Section", - ).Find( - &people, - fmt.Sprintf( - "is_contact = ? AND section_id IN ? AND (first_name LIKE ? OR last_name LIKE ?) AND deleted_at %s", - sqlFilterAppend, - ), - true, sqlFilterSections, sqlFilterSearch, sqlFilterSearch, - ) - - if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { - return err - } - } else { - result := db.Unscoped().Offset( - pagination.Offset, - ).Limit( - pagination.PageSize, - ).Order( - "concat(last_name, first_name) collate nocase asc", - ).Preload( - "Section", - ).Find( - &people, - fmt.Sprintf( - "is_contact = ? AND section_id IN ? AND deleted_at %s", - sqlFilterAppend, - ), - true, sqlFilterSections, - ) - - if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { - return err - } - } + params.PageNumber, _ = strconv.Atoi(c.Query("p")) + params.PageSize = 50 + params.PersonType = "contacts" var sections []models.Section - db.Order( - "name collate nocase asc", - ).Find( - §ions, - "contains_contacts = ? AND id IN ?", - true, sqlFilterSections, - ) + db.Order("name collate nocase asc").Find(§ions, "contains_contacts = ? AND id IN ?", true, allowedSections) + params.AllowedSections = allowedSections + + // Security for active contacts + if !permShow { + params.Active = false + } + + // Security for archived contacts + if !permShowArchived { + params.Archive = false + } + + results, err := database.PeopleSearch(params) + if err != nil { + return err + } + + var fields []models.Field + db.Order("position asc").Find(&fields, "person_type = ?", "contact") return c.Render("people", fiber.Map{ "PageTitle": "Contacts", "MembersPage": false, - "People": people, - "Pagination": pagination, + "People": results.Results, + "Pagination": results.Pagination, "PermShow": permShow, "PermShowArchived": permShowArchived, + "SearchJSON": searchJSON, "Sections": sections, - "FilterArchive": filterArchive, - "FilterSection": filterSectionUint, - "FilterSearch": filterSearch, + "Fields": fields, }) } @@ -206,75 +129,37 @@ func ContactsExport(c *fiber.Ctx) error { return err } - filterSection := c.Query("se") - filterArchive := c.Query("a") - filterSearch := c.Query("s") + searchJSON := c.Query("s") + var params database.PeopleSearchParams - sqlFilterSections := allowedSections - sqlFilterAppend := "IS NULL" - if filterArchive == "1" { - sqlFilterAppend = "IS NOT NULL" - sqlFilterSections = allowedSectionsArchived - } - - var filterSectionUint uint - if len(filterSection) > 0 { - filterSectionUint64, err := strconv.ParseUint(filterSection, 10, 0) - if err == nil { - for _, s := range sqlFilterSections { - if s == uint(filterSectionUint64) { - filterSectionUint = uint(filterSectionUint64) - } - } + if len(searchJSON) > 0 { + err = json.Unmarshal([]byte(searchJSON), ¶ms) + if err != nil { + log.Warn(err) + searchJSON = "" } } - if filterSectionUint > 0 { - sqlFilterAppend = fmt.Sprintf( - "%s AND section_id = %d", - sqlFilterAppend, - filterSectionUint, - ) + params.PageSize = 0 + params.PersonType = "members" + + var sections []models.Section + db.Order("name collate nocase asc").Find(§ions, "contains_members = ? AND id IN ?", true, allowedSections) + params.AllowedSections = allowedSections + + // Security for active contacts + if !permShow { + params.Active = false } - var sqlFilterSearch string - if len(filterSearch) > 0 { - sqlFilterSearch = fmt.Sprintf( - "%%%s%%", filterSearch, - ) + // Security for archived contacts + if !permShowArchived { + params.Archive = false } - var people []models.Person - if len(filterSearch) > 0 { - result := db.Unscoped().Order( - "last_name collate nocase asc, first_name collate nocase asc", - ).Preload("Section").Find( - &people, - fmt.Sprintf( - "is_contact = ? AND section_id IN ? AND (first_name LIKE ? OR last_name LIKE ?) AND deleted_at %s", - sqlFilterAppend, - ), - true, sqlFilterSections, sqlFilterSearch, sqlFilterSearch, - ) - - if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { - return err - } - } else { - result := db.Unscoped().Order( - "last_name collate nocase asc, first_name collate nocase asc", - ).Preload("Section").Find( - &people, - fmt.Sprintf( - "is_contact = ? AND section_id IN ? AND deleted_at %s", - sqlFilterAppend, - ), - true, sqlFilterSections, - ) - - if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { - return err - } + results, err := database.PeopleSearch(params) + if err != nil { + return err } var fields []models.Field @@ -324,7 +209,7 @@ func ContactsExport(c *fiber.Ctx) error { } } - for _, person := range people { + for _, person := range results.Results { c.Writef("\"%s\";", person.FirstName) c.Writef("\"%s\";", person.LastName) c.Writef("\"%s\";", person.Email) diff --git a/controllers/fields.go b/controllers/fields.go index 9507880..22f3d43 100644 --- a/controllers/fields.go +++ b/controllers/fields.go @@ -297,3 +297,25 @@ func FieldMoveDown(c *fiber.Ctx) error { return c.Redirect("/admin/fields") } + +func FieldJSON(c *fiber.Ctx) error { + id := c.Params("id") + + db, err := helpers.GetDatabase() + if err != nil { + return err + } + + var field models.Field + result := db.Preload("List").Preload("List.ListItems").Find(&field, "id = ?", id) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected < 1 { + return fiber.NewError(fiber.StatusNotFound, "Not found") + } + + return c.JSON(field) +} diff --git a/controllers/members.go b/controllers/members.go index 0548c70..b42d472 100644 --- a/controllers/members.go +++ b/controllers/members.go @@ -1,17 +1,18 @@ package controllers import ( - "errors" + "encoding/json" "fmt" "strconv" "strings" "time" "git.readonly.ch/bouzoure/pop-camarades/helpers" + "git.readonly.ch/bouzoure/pop-camarades/helpers/database" "git.readonly.ch/bouzoure/pop-camarades/models" + "github.com/charmbracelet/log" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" - "gorm.io/gorm" ) type PersonValidation struct { @@ -28,6 +29,8 @@ type PersonValidation struct { } func Members(c *fiber.Ctx) error { + log := helpers.GetLogger() + userid, err := helpers.GetSessionUserId(c) if err != nil { return err @@ -59,131 +62,53 @@ func Members(c *fiber.Ctx) error { return err } - filterSection := c.Query("se") - filterArchive := c.Query("a") - filterSearch := c.Query("s") + searchJSON := c.Query("s") + var params database.PeopleSearchParams - sqlFilterSections := allowedSections - sqlFilterAppend := "IS NULL" - if filterArchive == "1" { - sqlFilterAppend = "IS NOT NULL" - sqlFilterSections = allowedSectionsArchived - } - - var filterSectionUint uint - if len(filterSection) > 0 { - filterSectionUint64, err := strconv.ParseUint(filterSection, 10, 0) - if err == nil { - for _, s := range sqlFilterSections { - if s == uint(filterSectionUint64) { - filterSectionUint = uint(filterSectionUint64) - } - } + if len(searchJSON) > 0 { + err = json.Unmarshal([]byte(searchJSON), ¶ms) + if err != nil { + log.Warn(err) + searchJSON = "" } } - if filterSectionUint > 0 { - sqlFilterAppend = fmt.Sprintf( - "%s AND section_id = %d", - sqlFilterAppend, - filterSectionUint, - ) - } - - var sqlFilterSearch string - if len(filterSearch) > 0 { - sqlFilterSearch = fmt.Sprintf( - "%%%s%%", filterSearch, - ) - } - - var count int64 - if len(filterSearch) > 0 { - db.Model(&models.Person{}).Where( - fmt.Sprintf( - "is_member = ? AND section_id IN ? AND (first_name LIKE ? OR last_name LIKE ?) AND deleted_at %s", - sqlFilterAppend, - ), - true, allowedSections, sqlFilterSearch, sqlFilterSearch, - ).Count(&count) - } else { - db.Model(&models.Person{}).Where( - fmt.Sprintf( - "is_member = ? AND section_id IN ? AND deleted_at %s", - sqlFilterAppend, - ), - true, allowedSections, - ).Count(&count) - } - - page, _ := strconv.Atoi(c.Query("p")) - pagination := helpers.Paginate(50, int(count), page) - - var people []models.Person - if len(filterSearch) > 0 { - result := db.Unscoped().Offset( - pagination.Offset, - ).Limit( - pagination.PageSize, - ).Order( - "concat(last_name, first_name) collate nocase asc", - ).Preload( - "Section", - ).Find( - &people, - fmt.Sprintf( - "is_member = ? AND section_id IN ? AND (first_name LIKE ? OR last_name LIKE ?) AND deleted_at %s", - sqlFilterAppend, - ), - true, sqlFilterSections, sqlFilterSearch, sqlFilterSearch, - ) - - if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { - return err - } - } else { - result := db.Unscoped().Offset( - pagination.Offset, - ).Limit( - pagination.PageSize, - ).Order( - "concat(last_name, first_name) collate nocase asc", - ).Preload( - "Section", - ).Find( - &people, - fmt.Sprintf( - "is_member = ? AND section_id IN ? AND deleted_at %s", - sqlFilterAppend, - ), - true, sqlFilterSections, - ) - - if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { - return err - } - } + params.PageNumber, _ = strconv.Atoi(c.Query("p")) + params.PageSize = 50 + params.PersonType = "members" var sections []models.Section - db.Order( - "name collate nocase asc", - ).Find( - §ions, - "contains_members = ? AND id IN ?", - true, sqlFilterSections, - ) + db.Order("name collate nocase asc").Find(§ions, "contains_members = ? AND id IN ?", true, allowedSections) + params.AllowedSections = allowedSections + + // Security for active contacts + if !permShow { + params.Active = false + } + + // Security for archived contacts + if !permShowArchived { + params.Archive = false + } + + results, err := database.PeopleSearch(params) + if err != nil { + return err + } + + var fields []models.Field + db.Order("position asc").Find(&fields, "person_type = ?", "member") return c.Render("people", fiber.Map{ "PageTitle": "Membres", "MembersPage": true, - "People": people, - "Pagination": pagination, + "People": results.Results, + "Pagination": results.Pagination, "PermShow": permShow, "PermShowArchived": permShowArchived, + "SearchJSON": searchJSON, "Sections": sections, - "FilterArchive": filterArchive, - "FilterSection": filterSectionUint, - "FilterSearch": filterSearch, + "Fields": fields, }) } @@ -219,75 +144,37 @@ func MembersExport(c *fiber.Ctx) error { return err } - filterSection := c.Query("se") - filterArchive := c.Query("a") - filterSearch := c.Query("s") + searchJSON := c.Query("s") + var params database.PeopleSearchParams - sqlFilterSections := allowedSections - sqlFilterAppend := "IS NULL" - if filterArchive == "1" { - sqlFilterAppend = "IS NOT NULL" - sqlFilterSections = allowedSectionsArchived - } - - var filterSectionUint uint - if len(filterSection) > 0 { - filterSectionUint64, err := strconv.ParseUint(filterSection, 10, 0) - if err == nil { - for _, s := range sqlFilterSections { - if s == uint(filterSectionUint64) { - filterSectionUint = uint(filterSectionUint64) - } - } + if len(searchJSON) > 0 { + err = json.Unmarshal([]byte(searchJSON), ¶ms) + if err != nil { + log.Warn(err) + searchJSON = "" } } - if filterSectionUint > 0 { - sqlFilterAppend = fmt.Sprintf( - "%s AND section_id = %d", - sqlFilterAppend, - filterSectionUint, - ) + params.PageSize = 0 + params.PersonType = "members" + + var sections []models.Section + db.Order("name collate nocase asc").Find(§ions, "contains_members = ? AND id IN ?", true, allowedSections) + params.AllowedSections = allowedSections + + // Security for active contacts + if !permShow { + params.Active = false } - var sqlFilterSearch string - if len(filterSearch) > 0 { - sqlFilterSearch = fmt.Sprintf( - "%%%s%%", filterSearch, - ) + // Security for archived contacts + if !permShowArchived { + params.Archive = false } - var people []models.Person - if len(filterSearch) > 0 { - result := db.Unscoped().Order( - "last_name collate nocase asc, first_name collate nocase asc", - ).Preload("Section").Find( - &people, - fmt.Sprintf( - "is_member = ? AND section_id IN ? AND (first_name LIKE ? OR last_name LIKE ?) AND deleted_at %s", - sqlFilterAppend, - ), - true, sqlFilterSections, sqlFilterSearch, sqlFilterSearch, - ) - - if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { - return err - } - } else { - result := db.Unscoped().Order( - "last_name collate nocase asc, first_name collate nocase asc", - ).Preload("Section").Find( - &people, - fmt.Sprintf( - "is_member = ? AND section_id IN ? AND deleted_at %s", - sqlFilterAppend, - ), - true, sqlFilterSections, - ) - - if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { - return err - } + results, err := database.PeopleSearch(params) + if err != nil { + return err } var fields []models.Field @@ -337,7 +224,7 @@ func MembersExport(c *fiber.Ctx) error { } } - for _, person := range people { + for _, person := range results.Results { c.Writef("\"%s\";", person.FirstName) c.Writef("\"%s\";", person.LastName) c.Writef("\"%s\";", person.Email) diff --git a/helpers/database/people.go b/helpers/database/people.go new file mode 100644 index 0000000..ef4d80e --- /dev/null +++ b/helpers/database/people.go @@ -0,0 +1,349 @@ +package database + +import ( + "database/sql" + "fmt" + "strings" + + "git.readonly.ch/bouzoure/pop-camarades/helpers" + "git.readonly.ch/bouzoure/pop-camarades/models" +) + +type PeopleSearchParams struct { + // JSON, provided by user + Advanced bool `json:"advanced"` + Name string `json:"name"` + Section uint `json:"section"` + Active bool `json:"active"` + Archive bool `json:"archive"` + Email string `json:"email"` + Phone string `json:"phone"` + Address string `json:"address"` + PostalCode string `json:"postal_code"` + City string `json:"city"` + Fields map[uint][]any `json:"fields"` + // Not JSON, defined in backend + PersonType string `json:"-"` + PageSize int `json:"-"` + PageNumber int `json:"-"` + AllowedSections []uint `json:"-"` +} + +type PeopleSearchResults struct { + Results []models.Person + Count int64 + Pagination helpers.Pagination +} + +func PeopleSearch(params PeopleSearchParams) (PeopleSearchResults, error) { + var results PeopleSearchResults + + db, err := helpers.GetDatabase() + if err != nil { + return results, nil + } + + // SQL qeury for results + sqlQuery := `--sql + SELECT people.*, + sections.name AS Section__name + FROM people + INNER JOIN sections + ON people.section_id = sections.id` + + // SQL query to count results + sqlQueryCount := `--sql + SELECT people.id + FROM people + ` + + // Create filters for both queries + var sqlParams []any + var sqlFilters string + + // Filter: member or contact + sqlFilters = `--sql + WHERE is_member = @is_member + AND is_contact = @is_contact + ` + if params.PersonType == "members" { + sqlParams = append(sqlParams, sql.Named("is_member", true)) + sqlParams = append(sqlParams, sql.Named("is_contact", false)) + } else if params.PersonType == "contacts" { + sqlParams = append(sqlParams, sql.Named("is_member", false)) + sqlParams = append(sqlParams, sql.Named("is_contact", true)) + } else { + return results, fmt.Errorf("unkown person type") + } + + // Filter: name -> first_name + last_name + if len(params.Name) > 0 { + nameFilter := `--sql + people.first_name LIKE @name + OR people.last_name LIKE @name + OR CONCAT(people.first_name, ' ', people.last_name) LIKE @name + OR CONCAT(people.last_name, ' ', people.first_name) LIKE @name + ` + sqlParams = append(sqlParams, sql.Named( + "name", + fmt.Sprintf("%%%s%%", params.Name), + )) + + if strings.Contains(params.Name, " ") { + names := strings.Split(params.Name, " ") + for index, name := range names { + nameFilter = fmt.Sprintf(`--sql + %s + OR people.first_name LIKE @name_%d + OR people.last_name LIKE @name_%d + `, nameFilter, index, index) + + sqlParams = append(sqlParams, sql.Named( + fmt.Sprintf("name_%d", index), + fmt.Sprintf("%%%s%%", name), + )) + } + } + + sqlFilters = fmt.Sprintf(`--sql + %s + AND (%s) + `, sqlFilters, nameFilter) + } + + // Filter: section + if params.Section > 0 { + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.section_id = @section + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named("section", params.Section)) + } + + // Filter: active (only apply if archived is false) + if params.Active && !params.Archive { + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.deleted_at IS NULL + `, sqlFilters) + } + + // Filter: archived (only apply if active is false) + if params.Archive && !params.Active { + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.deleted_at IS NOT NULL + `, sqlFilters) + } + + // Filters: email + if len(params.Email) > 0 { + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.email LIKE @email + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named( + "email", + fmt.Sprintf("%%%s%%", params.Email), + )) + } + + // Filters: phone + if len(params.Phone) > 0 { + params.Phone = strings.ReplaceAll(params.Phone, " ", "") + params.Phone = strings.ReplaceAll(params.Phone, "-", "") + params.Phone = strings.ReplaceAll(params.Phone, ".", "") + params.Phone = strings.ReplaceAll(params.Phone, "/", "") + params.Phone = strings.ReplaceAll(params.Phone, "+", "") + + sqlFilters = fmt.Sprintf(`--sql + %s + AND ( + REPLACE( + REPLACE( + REPLACE( + REPLACE( + REPLACE(people.phone, ' ', '') + , '-', '') + , '.', '') + , '/', '') + , '+', '') LIKE @phone + OR REPLACE( + REPLACE( + REPLACE( + REPLACE( + REPLACE(people.mobile, ' ', '') + , '-', '') + , '.', '') + , '/', '') + , '+', '') LIKE @phone + ) + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named( + "phone", + fmt.Sprintf("%%%s%%", params.Phone), + )) + } + + // Filters: address + if len(params.Address) > 0 { + sqlFilters = fmt.Sprintf(`--sql + %s + AND ( + people.address1 LIKE @address + OR people.address2 LIKE @address + ) + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named( + "address", + fmt.Sprintf("%%%s%%", params.Address), + )) + } + + // Filters: postal code + if len(params.PostalCode) > 0 { + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.postal_code = @postal_code + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named( + "postal_code", + params.PostalCode, + )) + } + + // Filters: city + if len(params.City) > 0 { + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.city LIKE @city + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named( + "city", + fmt.Sprintf("%%%s%%", params.City), + )) + } + + // Security filter: only show results in allowed secitons + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.section_id IN @allowed_sections + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named("allowed_sections", params.AllowedSections)) + + // Filter: optional fields + var sqlFieldJoins string + for fieldID, values := range params.Fields { + var field models.Field + db.First(&field, "id = ?", fieldID) + + // Skip if field ID not found in DB + if field.ID <= 0 { + continue + } + + sqlFieldJoins = fmt.Sprintf(`--sql + %s + LEFT JOIN field_values + AS field_%d + ON people.id = field_%d.person_id + AND field_%d.field_id = @field_%d_id + `, sqlFieldJoins, field.ID, field.ID, field.ID, field.ID) + + sqlParams = append(sqlParams, sql.Named( + fmt.Sprintf("field_%d_id", field.ID), + field.ID, + )) + + var fieldFilter string + for index, value := range values { + var filter string + if fieldFilter != "" { + filter = "OR" + } + + switch v := value.(type) { + default: + fmt.Println(v) + continue + case string: + filter = fmt.Sprintf(`--sql + %s + field_%d.value_string LIKE @field_%d_%d + OR field_%d.value_date LIKE @field_%d_%d + `, filter, field.ID, field.ID, index, field.ID, field.ID, index) + sqlParams = append(sqlParams, sql.Named( + fmt.Sprintf("field_%d_%d", field.ID, index), + fmt.Sprintf("%%%s%%", v), + )) + case float64: + filter = fmt.Sprintf(`--sql + %s + field_%d.value_int = @field_%d_%d + OR field_%d.list_item_id = @field_%d_%d + `, filter, field.ID, field.ID, index, field.ID, field.ID, index) + sqlParams = append(sqlParams, sql.Named( + fmt.Sprintf("field_%d_%d", field.ID, index), + v, + )) + } + + fieldFilter = fmt.Sprintf("%s %s", fieldFilter, filter) + } + + if fieldFilter != "" { + sqlFilters = fmt.Sprintf(`--sql + %s + AND (%s) + `, sqlFilters, fieldFilter) + } + } + + // Build and run count query + sqlQueryCount = fmt.Sprintf(`--sql + %s + %s + %s + GROUP BY people.id + `, sqlQueryCount, sqlFieldJoins, sqlFilters) + + var count []uint + sqlCountResult := db.Raw(sqlQueryCount, sqlParams...).Scan(&count) + if sqlCountResult.Error != nil { + return results, sqlCountResult.Error + } + + // Create pagination with count + results.Pagination = helpers.Paginate( + params.PageSize, + len(count), + params.PageNumber, + ) + + var sqlPagination string + if params.PageSize > 0 { + sqlPagination = `--sql + LIMIT @pagination_limit + OFFSET @pagination_offset + ` + sqlParams = append(sqlParams, sql.Named("pagination_limit", results.Pagination.PageSize)) + sqlParams = append(sqlParams, sql.Named("pagination_offset", results.Pagination.Offset)) + } + + // Build and run paginated result query + sqlQuery = fmt.Sprintf(`--sql + %s + %s + %s + GROUP BY people.id + ORDER BY CONCAT(people.last_name, people.first_name) COLLATE NOCASE ASC + %s + `, sqlQuery, sqlFieldJoins, sqlFilters, sqlPagination) + + sqlResult := db.Raw(sqlQuery, sqlParams...).Scan(&results.Results) + if sqlResult.Error != nil { + return results, sqlResult.Error + } + + return results, nil +} diff --git a/main.go b/main.go index 9d79f3e..fe2b952 100644 --- a/main.go +++ b/main.go @@ -167,6 +167,9 @@ func main() { app.Post("/contacts/:id/restore", controllers.ContactRestore) app.Post("/contacts/:id/purge", controllers.ContactPurge) + // Fields + app.Get("/fields/:id", controllers.FieldJSON) + // Account manage app.Get("/account/manage", controllers.AccountManage) app.Post("/account/manage", controllers.AccountManage) diff --git a/static/search.js b/static/search.js new file mode 100644 index 0000000..825bfac --- /dev/null +++ b/static/search.js @@ -0,0 +1,199 @@ +$(document).ready(function() { + $("#search").on("click", function() { + search(); + }); + + $("#advanced").on("click", function() { + if ($(this).data("state") === "true") { + $(this).find("i").removeClass("bi-arrow-up"); + $(this).find("i").addClass("bi-arrow-down"); + $("#advanced-section").addClass("d-none"); + $(this).data("state", "false"); + } else { + $(this).find("i").removeClass("bi-arrow-down"); + $(this).find("i").addClass("bi-arrow-up"); + $("#advanced-section").removeClass("d-none"); + $(this).data("state", "true"); + } + }); + + $("#reset-search").on("click", function() { + $("#search-json").val(""); + $("#search-page").val("1"); + $("#search-form").submit(); + }); + + $("#search-container").on("change", ".field-select", function() { + var lastField = $(this).attr("data-last-field"); + var isInitial = $(this).attr("data-is-initial"); + var initialValue = $(this).attr("data-initial-value"); + + var field = $(this).val(); + if (field == "") { + $(this).parents(".row").remove(); + return; + } + + if ((lastField === undefined || lastField.length === 0) && (isInitial === undefined || isInitial !== "yes")) { + createFieldSelection(); + } + + $(this).attr("data-last-field", field); + + $(this).parents(".row").find(".end-col").find("input").remove(); + $(this).parents(".row").find(".end-col").find("select").remove(); + + $(elem).parents(".row").find(".end-col").append($("", { + class: "form-control", + type: "text", + disabled: true, + })); + + var elem = $(this); + $.getJSON("/fields/" + field, function(data) { + $(this).parents(".row").find(".end-col").find("input").remove(); + + if (data.FieldType === "list") { + var select = $("", { + class: "form-control", + type: "number", + "data-optional-field": field, + "data-search-type": "int" + })); + } else if (data.FieldType === "date") { + $(elem).parents(".row").find(".end-col").append($("", { + class: "form-control", + type: "date", + "data-optional-field": field + })); + } else { + $(elem).parents(".row").find(".end-col").append($("", { + class: "form-control", + type: "text", + "data-optional-field": field + })); + } + + if (initialValue !== undefined && initialValue.length > 0) { + $(elem).parents(".row").find("[data-optional-field]").val(initialValue); + $(elem).attr("data-initial-value", null); + } + }); + }); + + $("#search-container").on("keyup", "input[type=text]", function(e) { + if (e.keyCode === 13) { + search(); + } + }); + + var json = $("#search-json").val(); + if (json.length > 0) { + var searchData = JSON.parse(json); + + for (const [key, value] of Object.entries(searchData)) { + if (key === "advanced") { + if (value) $("#advanced").trigger("click"); + continue; + } + + if (key === "fields") { + for (const [field_id, field_values] of Object.entries(value)) { + for (const field_value of field_values) { + createFieldSelection(field_id, field_value); + } + } + continue + } + + if (typeof value === "boolean") { + $("[data-search-field=" + key + "]").prop("checked", value); + } else { + $("[data-search-field=" + key + "]").val(value); + } + } + } + + createFieldSelection(); +}); + +function createFieldSelection(field, value) { + var elem = $("#search-fields-model").clone().appendTo("#search-fields"); + $(elem).attr("id", null); + $(elem).removeClass("d-none"); + $(elem).addClass("search-fields-count"); + + if (field !== undefined && field.length > 0) { + $(elem).find(".field-select").attr("data-initial-value", value); + $(elem).find(".field-select").attr("data-is-initial", "yes"); + $(elem).find(".field-select").val(field); + $(elem).find(".field-select").trigger("change"); + } +} + +function search() { + var advancedSearch = ($("#advanced").data("state") === "true"); + var searchData = { + advanced: advancedSearch + }; + + $("[data-search-field]").each(function() { + var advancedField = $(this).data("search-advanced"); + if (!advancedSearch && advancedField) { + return; + } + + var index = $(this).data("search-field"); + var value = $(this).val(); + + if ($(this).data("search-type") === "int") { + value = parseInt(value); + if (isNaN(value)) return; + } else if ($(this).attr("type") === "checkbox") { + value = $(this).prop("checked"); + } + + searchData[index] = value; + }); + + fields = {} + $("[data-optional-field]:not(:disabled)").each(function() { + if (!advancedSearch) { + return; + } + + var index = $(this).attr("data-optional-field"); + var value = $(this).val(); + + if ($(this).data("search-type") === "int") { + value = parseInt(value); + if (isNaN(value)) return; + } + + if (fields[index] !== undefined) { + fields[index].push(value); + } else { + fields[index] = [value]; + } + }); + searchData["fields"] = fields; + + var json = JSON.stringify(searchData); + $("#search-json").val(json); + $("#search-page").val("1"); + $("#search-form").submit(); +} \ No newline at end of file diff --git a/views/people.html b/views/people.html index d51c34c..292734b 100644 --- a/views/people.html +++ b/views/people.html @@ -25,7 +25,7 @@ Ajouter - + Exporter @@ -36,7 +36,7 @@ Ajouter - + Exporter @@ -44,98 +44,130 @@ {% endif %} -
- - +
-
+
- - + +
+
+ + +
-
- - - + {% if MembersPage %} + + {% else %} + {% endif %} - > - Archivé - - {% endif %} - +
+ {% endif %} -
-
+ {% if PermShowArchived %} +
+ + {% if MembersPage %} + + {% else %} + + {% endif %} +
+ {% endif %} - -
- - -
-
- + +
+
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+ + +
+
@@ -196,7 +228,7 @@
  • @@ -204,7 +236,7 @@
  • @@ -220,7 +252,7 @@ class="page-link" {% endif %} - href="?se={{ FilterSection }}&s={{ FilterSearch|urlencode }}&a={{ FilterArchive }}&p={{ i }}" + href="?&p={{ i }}&s={{ SearchJSON|urlencode }}" > {{ i }} @@ -242,7 +274,7 @@
  • @@ -250,7 +282,7 @@
  • @@ -263,11 +295,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} \ No newline at end of file diff --git a/views/person.html b/views/person.html index b830ce0..f019eb2 100644 --- a/views/person.html +++ b/views/person.html @@ -234,10 +234,11 @@ {% set count = 1 %} {% for FieldValue in FieldValues %} {% if FieldValue.FieldID == Field.ID %} - {% set count = count + 1 %} - {% if forloop.Last %} - + {% if FieldValue.FieldID == Field.ID %} +
    + + +
    {% endif %} {% endif %} {% endfor %}