reports kind of working?
This commit is contained in:
39
README.MD
39
README.MD
@@ -1,38 +1,5 @@
|
||||
# Poll-system
|
||||
|
||||
# ADDRESSES:
|
||||
|
||||
- A giant dataset of all the addresses and their log,lat location (not interactive)
|
||||
- A user able to see his ward addresses
|
||||
- Assing the address to a user whose role is leader or volunteer
|
||||
- mass assign addresses to the user, multiple houses can be assined ith tiem left blank
|
||||
- we can assing only after checking id the volunter is free on that day and time
|
||||
- volunteer schedualing their time and date
|
||||
- view the volunteers schedualling preference
|
||||
|
||||
# TODO
|
||||
|
||||
## VOLUNTEER
|
||||
|
||||
- Volunteer Schdual
|
||||
- Appointment
|
||||
|
||||
## APPOINTMENT
|
||||
|
||||
```sql
|
||||
create table user_addresses
|
||||
(
|
||||
user_id integer not null
|
||||
references users
|
||||
on delete cascade,
|
||||
address_line1 varchar(200) not null,
|
||||
address_line2 varchar(200),
|
||||
city varchar(100),
|
||||
province varchar(100),
|
||||
country varchar(100),
|
||||
postal_code varchar(20) not null,
|
||||
created_at timestamp default now(),
|
||||
updated_at timestamp default now(),
|
||||
primary key (user_id, address_line1, postal_code)
|
||||
);
|
||||
```
|
||||
- TODO: Volunteer scehdual database
|
||||
- TODO: Map View
|
||||
- TODO: CSV smart address upload
|
||||
|
||||
824
app/internal/handlers/admin_reports.go
Normal file
824
app/internal/handlers/admin_reports.go
Normal file
@@ -0,0 +1,824 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/patel-mann/poll-system/app/internal/models"
|
||||
"github.com/patel-mann/poll-system/app/internal/utils"
|
||||
)
|
||||
|
||||
// ReportData represents the combined data for reports
|
||||
type ReportData struct {
|
||||
Users []models.User
|
||||
Polls []PollWithDetails
|
||||
Appointments []AppointmentWithDetails
|
||||
Addresses []models.AddressDatabase
|
||||
Teams []TeamWithDetails
|
||||
TotalUsers int
|
||||
TotalPolls int
|
||||
TotalAddresses int
|
||||
}
|
||||
|
||||
type PollWithDetails struct {
|
||||
PollID int `json:"poll_id"`
|
||||
UserID int `json:"user_id"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AddressID int `json:"address_id"`
|
||||
Address string `json:"address"`
|
||||
PollTitle string `json:"poll_title"`
|
||||
PollDescription string `json:"poll_description"`
|
||||
IsActive bool `json:"is_active"`
|
||||
AmountDonated float64 `json:"amount_donated"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AppointmentWithDetails struct {
|
||||
SchedID int `json:"sched_id"`
|
||||
UserID int `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
AddressID int `json:"address_id"`
|
||||
Address string `json:"address"`
|
||||
AppointmentDate time.Time `json:"appointment_date"`
|
||||
AppointmentTime time.Time `json:"appointment_time"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type TeamWithDetails struct {
|
||||
TeamID int `json:"team_id"`
|
||||
TeamLeadID int `json:"team_lead_id"`
|
||||
TeamLeadName string `json:"team_lead_name"`
|
||||
VolunteerID int `json:"volunteer_id"`
|
||||
VolunteerName string `json:"volunteer_name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ReportHandler handles the report page with search and filter functionality
|
||||
func ReportHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// currentUserID := r.Context().Value("user_id").(int)
|
||||
username, _ := models.GetCurrentUserName(r)
|
||||
role := r.Context().Value("user_role").(int)
|
||||
|
||||
// Check if user has permission to view reports
|
||||
if role != 1 { // Assuming role 1 is admin
|
||||
http.Error(w, "Unauthorized", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters for filtering
|
||||
searchType := r.URL.Query().Get("search_type") // users, polls, appointments, addresses, teams
|
||||
searchQuery := r.URL.Query().Get("search_query") // general search term
|
||||
dateFrom := r.URL.Query().Get("date_from")
|
||||
dateTo := r.URL.Query().Get("date_to")
|
||||
roleFilter := r.URL.Query().Get("role_filter")
|
||||
statusFilter := r.URL.Query().Get("status_filter") // active, inactive for polls
|
||||
sortBy := r.URL.Query().Get("sort_by") // created_at, name, email, etc.
|
||||
sortOrder := r.URL.Query().Get("sort_order") // asc, desc
|
||||
page := r.URL.Query().Get("page")
|
||||
limit := r.URL.Query().Get("limit")
|
||||
|
||||
// Set defaults
|
||||
if sortBy == "" {
|
||||
sortBy = "created_at"
|
||||
}
|
||||
if sortOrder == "" {
|
||||
sortOrder = "desc"
|
||||
}
|
||||
if page == "" {
|
||||
page = "1"
|
||||
}
|
||||
if limit == "" {
|
||||
limit = "50"
|
||||
}
|
||||
|
||||
pageInt, _ := strconv.Atoi(page)
|
||||
limitInt, _ := strconv.Atoi(limit)
|
||||
offset := (pageInt - 1) * limitInt
|
||||
|
||||
reportData := ReportData{}
|
||||
|
||||
// Build queries based on search type and filters
|
||||
switch searchType {
|
||||
case "users":
|
||||
reportData.Users = searchUsers(searchQuery, roleFilter, dateFrom, dateTo, sortBy, sortOrder, limitInt, offset)
|
||||
reportData.TotalUsers = countUsers(searchQuery, roleFilter, dateFrom, dateTo)
|
||||
case "polls":
|
||||
reportData.Polls = searchPolls(searchQuery, statusFilter, dateFrom, dateTo, sortBy, sortOrder, limitInt, offset)
|
||||
reportData.TotalPolls = countPolls(searchQuery, statusFilter, dateFrom, dateTo)
|
||||
case "appointments":
|
||||
reportData.Appointments = searchAppointments(searchQuery, dateFrom, dateTo, sortBy, sortOrder, limitInt, offset)
|
||||
case "addresses":
|
||||
reportData.Addresses = searchAddresses(searchQuery, dateFrom, dateTo, sortBy, sortOrder, limitInt, offset)
|
||||
reportData.TotalAddresses = countAddresses(searchQuery, dateFrom, dateTo)
|
||||
case "teams":
|
||||
reportData.Teams = searchTeams(searchQuery, dateFrom, dateTo, sortBy, sortOrder, limitInt, offset)
|
||||
default:
|
||||
// Load summary data for all types
|
||||
reportData.Users = searchUsers("", "", "", "", "created_at", "desc", 10, 0)
|
||||
reportData.Polls = searchPolls("", "", "", "", "created_at", "desc", 10, 0)
|
||||
reportData.Appointments = searchAppointments("", "", "", "created_at", "desc", 10, 0)
|
||||
reportData.Addresses = searchAddresses("", "", "", "created_at", "desc", 10, 0)
|
||||
reportData.Teams = searchTeams("", "", "", "created_at", "desc", 10, 0)
|
||||
reportData.TotalUsers = countUsers("", "", "", "")
|
||||
reportData.TotalPolls = countPolls("", "", "", "")
|
||||
reportData.TotalAddresses = countAddresses("", "", "")
|
||||
}
|
||||
|
||||
adminnav := role == 1
|
||||
volunteernav := role != 1
|
||||
|
||||
utils.Render(w, "reports.html", map[string]interface{}{
|
||||
"Title": "Reports & Analytics",
|
||||
"IsAuthenticated": true,
|
||||
"ShowAdminNav": adminnav,
|
||||
"ShowVolunteerNav": volunteernav,
|
||||
"UserName": username,
|
||||
"ActiveSection": "reports",
|
||||
"ReportData": reportData,
|
||||
"SearchType": searchType,
|
||||
"SearchQuery": searchQuery,
|
||||
"DateFrom": dateFrom,
|
||||
"DateTo": dateTo,
|
||||
"RoleFilter": roleFilter,
|
||||
"StatusFilter": statusFilter,
|
||||
"SortBy": sortBy,
|
||||
"SortOrder": sortOrder,
|
||||
"CurrentPage": pageInt,
|
||||
"Limit": limitInt,
|
||||
})
|
||||
}
|
||||
|
||||
// searchUsers searches users with filters
|
||||
func searchUsers(searchQuery, roleFilter, dateFrom, dateTo, sortBy, sortOrder string, limit, offset int) []models.User {
|
||||
var users []models.User
|
||||
|
||||
query := `
|
||||
SELECT u.user_id, u.first_name, u.last_name, u.email, u.phone, u.role_id, u.created_at, u.updated_at, u.admin_code
|
||||
FROM users u
|
||||
LEFT JOIN role r ON u.role_id = r.role_id
|
||||
WHERE 1=1`
|
||||
|
||||
var args []interface{}
|
||||
argCount := 0
|
||||
|
||||
// Add search conditions
|
||||
if searchQuery != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND (LOWER(u.first_name) LIKE LOWER($%d) OR LOWER(u.last_name) LIKE LOWER($%d) OR LOWER(u.email) LIKE LOWER($%d))`, argCount, argCount, argCount)
|
||||
args = append(args, "%"+searchQuery+"%")
|
||||
}
|
||||
|
||||
if roleFilter != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND u.role_id = $%d`, argCount)
|
||||
roleID, _ := strconv.Atoi(roleFilter)
|
||||
args = append(args, roleID)
|
||||
}
|
||||
|
||||
if dateFrom != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND u.created_at >= $%d`, argCount)
|
||||
args = append(args, dateFrom)
|
||||
}
|
||||
|
||||
if dateTo != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND u.created_at <= $%d`, argCount)
|
||||
args = append(args, dateTo+" 23:59:59")
|
||||
}
|
||||
|
||||
// Add sorting
|
||||
validSortColumns := map[string]bool{"created_at": true, "first_name": true, "last_name": true, "email": true}
|
||||
if !validSortColumns[sortBy] {
|
||||
sortBy = "created_at"
|
||||
}
|
||||
if sortOrder != "asc" && sortOrder != "desc" {
|
||||
sortOrder = "desc"
|
||||
}
|
||||
query += fmt.Sprintf(` ORDER BY u.%s %s`, sortBy, strings.ToUpper(sortOrder))
|
||||
|
||||
// Add pagination
|
||||
argCount++
|
||||
query += fmt.Sprintf(` LIMIT $%d`, argCount)
|
||||
args = append(args, limit)
|
||||
argCount++
|
||||
query += fmt.Sprintf(` OFFSET $%d`, argCount)
|
||||
args = append(args, offset)
|
||||
|
||||
rows, err := models.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
log.Println("Error searching users:", err)
|
||||
return users
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var user models.User
|
||||
err := rows.Scan(&user.UserID, &user.FirstName, &user.LastName, &user.Email, &user.Phone, &user.RoleID, &user.CreatedAt, &user.UpdatedAt, &user.AdminCode)
|
||||
if err != nil {
|
||||
log.Println("Error scanning user:", err)
|
||||
continue
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
return users
|
||||
}
|
||||
|
||||
// searchPolls searches polls with filters
|
||||
func searchPolls(searchQuery, statusFilter, dateFrom, dateTo, sortBy, sortOrder string, limit, offset int) []PollWithDetails {
|
||||
var polls []PollWithDetails
|
||||
|
||||
query := `
|
||||
SELECT p.poll_id, p.user_id, COALESCE(u.first_name || ' ' || u.last_name, 'Unknown') as author_name,
|
||||
p.address_id, COALESCE(a.address, 'No Address') as address,
|
||||
p.poll_title, p.poll_description, p.is_active, p.amount_donated, p.created_at, p.updated_at
|
||||
FROM poll p
|
||||
LEFT JOIN users u ON p.user_id = u.user_id
|
||||
LEFT JOIN address_database a ON p.address_id = a.address_id
|
||||
WHERE 1=1`
|
||||
|
||||
var args []interface{}
|
||||
argCount := 0
|
||||
|
||||
if searchQuery != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND (LOWER(p.poll_title) LIKE LOWER($%d) OR LOWER(p.poll_description) LIKE LOWER($%d))`, argCount, argCount)
|
||||
args = append(args, "%"+searchQuery+"%")
|
||||
}
|
||||
|
||||
if statusFilter == "active" {
|
||||
query += ` AND p.is_active = true`
|
||||
} else if statusFilter == "inactive" {
|
||||
query += ` AND p.is_active = false`
|
||||
}
|
||||
|
||||
if dateFrom != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND p.created_at >= $%d`, argCount)
|
||||
args = append(args, dateFrom)
|
||||
}
|
||||
|
||||
if dateTo != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND p.created_at <= $%d`, argCount)
|
||||
args = append(args, dateTo+" 23:59:59")
|
||||
}
|
||||
|
||||
validSortColumns := map[string]bool{"created_at": true, "poll_title": true, "amount_donated": true, "is_active": true}
|
||||
if !validSortColumns[sortBy] {
|
||||
sortBy = "created_at"
|
||||
}
|
||||
if sortOrder != "asc" && sortOrder != "desc" {
|
||||
sortOrder = "desc"
|
||||
}
|
||||
query += fmt.Sprintf(` ORDER BY p.%s %s`, sortBy, strings.ToUpper(sortOrder))
|
||||
|
||||
argCount++
|
||||
query += fmt.Sprintf(` LIMIT $%d`, argCount)
|
||||
args = append(args, limit)
|
||||
argCount++
|
||||
query += fmt.Sprintf(` OFFSET $%d`, argCount)
|
||||
args = append(args, offset)
|
||||
|
||||
rows, err := models.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
log.Println("Error searching polls:", err)
|
||||
return polls
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var poll PollWithDetails
|
||||
err := rows.Scan(&poll.PollID, &poll.UserID, &poll.AuthorName, &poll.AddressID, &poll.Address,
|
||||
&poll.PollTitle, &poll.PollDescription, &poll.IsActive, &poll.AmountDonated, &poll.CreatedAt, &poll.UpdatedAt)
|
||||
if err != nil {
|
||||
log.Println("Error scanning poll:", err)
|
||||
continue
|
||||
}
|
||||
polls = append(polls, poll)
|
||||
}
|
||||
|
||||
return polls
|
||||
}
|
||||
|
||||
// searchAppointments searches appointments with filters
|
||||
func searchAppointments(searchQuery, dateFrom, dateTo, sortBy, sortOrder string, limit, offset int) []AppointmentWithDetails {
|
||||
var appointments []AppointmentWithDetails
|
||||
|
||||
query := `
|
||||
SELECT a.sched_id, a.user_id, COALESCE(u.first_name || ' ' || u.last_name, 'Unknown') as user_name,
|
||||
a.address_id, COALESCE(ad.address, 'No Address') as address,
|
||||
a.appointment_date, a.appointment_time, a.created_at
|
||||
FROM appointment a
|
||||
LEFT JOIN users u ON a.user_id = u.user_id
|
||||
LEFT JOIN address_database ad ON a.address_id = ad.address_id
|
||||
WHERE 1=1`
|
||||
|
||||
var args []interface{}
|
||||
argCount := 0
|
||||
|
||||
if searchQuery != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND (LOWER(u.first_name) LIKE LOWER($%d) OR LOWER(u.last_name) LIKE LOWER($%d) OR LOWER(ad.address) LIKE LOWER($%d))`, argCount, argCount, argCount)
|
||||
args = append(args, "%"+searchQuery+"%")
|
||||
}
|
||||
|
||||
if dateFrom != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND a.appointment_date >= $%d`, argCount)
|
||||
args = append(args, dateFrom)
|
||||
}
|
||||
|
||||
if dateTo != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND a.appointment_date <= $%d`, argCount)
|
||||
args = append(args, dateTo)
|
||||
}
|
||||
|
||||
validSortColumns := map[string]bool{"created_at": true, "appointment_date": true, "appointment_time": true}
|
||||
if !validSortColumns[sortBy] {
|
||||
sortBy = "appointment_date"
|
||||
}
|
||||
if sortOrder != "asc" && sortOrder != "desc" {
|
||||
sortOrder = "desc"
|
||||
}
|
||||
query += fmt.Sprintf(` ORDER BY a.%s %s`, sortBy, strings.ToUpper(sortOrder))
|
||||
|
||||
argCount++
|
||||
query += fmt.Sprintf(` LIMIT $%d`, argCount)
|
||||
args = append(args, limit)
|
||||
argCount++
|
||||
query += fmt.Sprintf(` OFFSET $%d`, argCount)
|
||||
args = append(args, offset)
|
||||
|
||||
rows, err := models.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
log.Println("Error searching appointments:", err)
|
||||
return appointments
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var apt AppointmentWithDetails
|
||||
var appointmentTime sql.NullTime
|
||||
err := rows.Scan(&apt.SchedID, &apt.UserID, &apt.UserName, &apt.AddressID, &apt.Address,
|
||||
&apt.AppointmentDate, &appointmentTime, &apt.CreatedAt)
|
||||
if err != nil {
|
||||
log.Println("Error scanning appointment:", err)
|
||||
continue
|
||||
}
|
||||
if appointmentTime.Valid {
|
||||
apt.AppointmentTime = appointmentTime.Time
|
||||
}
|
||||
appointments = append(appointments, apt)
|
||||
}
|
||||
|
||||
return appointments
|
||||
}
|
||||
|
||||
// searchAddresses searches addresses with filters
|
||||
func searchAddresses(searchQuery, dateFrom, dateTo, sortBy, sortOrder string, limit, offset int) []models.AddressDatabase {
|
||||
var addresses []models.AddressDatabase
|
||||
|
||||
query := `
|
||||
SELECT address_id, address, street_name, street_type, street_quadrant,
|
||||
house_number, house_alpha, longitude, latitude, visited_validated, created_at, updated_at
|
||||
FROM address_database
|
||||
WHERE 1=1`
|
||||
|
||||
var args []interface{}
|
||||
argCount := 0
|
||||
|
||||
if searchQuery != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND (LOWER(address) LIKE LOWER($%d) OR LOWER(street_name) LIKE LOWER($%d) OR house_number LIKE $%d)`, argCount, argCount, argCount)
|
||||
args = append(args, "%"+searchQuery+"%")
|
||||
}
|
||||
|
||||
if dateFrom != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND created_at >= $%d`, argCount)
|
||||
args = append(args, dateFrom)
|
||||
}
|
||||
|
||||
if dateTo != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND created_at <= $%d`, argCount)
|
||||
args = append(args, dateTo+" 23:59:59")
|
||||
}
|
||||
|
||||
validSortColumns := map[string]bool{"created_at": true, "address": true, "street_name": true, "visited_validated": true}
|
||||
if !validSortColumns[sortBy] {
|
||||
sortBy = "created_at"
|
||||
}
|
||||
if sortOrder != "asc" && sortOrder != "desc" {
|
||||
sortOrder = "desc"
|
||||
}
|
||||
query += fmt.Sprintf(` ORDER BY %s %s`, sortBy, strings.ToUpper(sortOrder))
|
||||
|
||||
argCount++
|
||||
query += fmt.Sprintf(` LIMIT $%d`, argCount)
|
||||
args = append(args, limit)
|
||||
argCount++
|
||||
query += fmt.Sprintf(` OFFSET $%d`, argCount)
|
||||
args = append(args, offset)
|
||||
|
||||
rows, err := models.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
log.Println("Error searching addresses:", err)
|
||||
return addresses
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var addr models.AddressDatabase
|
||||
err := rows.Scan(&addr.AddressID, &addr.Address, &addr.StreetName, &addr.StreetType, &addr.StreetQuadrant,
|
||||
&addr.HouseNumber, &addr.HouseAlpha, &addr.Longitude, &addr.Latitude, &addr.VisitedValidated, &addr.CreatedAt, &addr.UpdatedAt)
|
||||
if err != nil {
|
||||
log.Println("Error scanning address:", err)
|
||||
continue
|
||||
}
|
||||
addresses = append(addresses, addr)
|
||||
}
|
||||
|
||||
return addresses
|
||||
}
|
||||
|
||||
// searchTeams searches teams with filters
|
||||
func searchTeams(searchQuery, dateFrom, dateTo, sortBy, sortOrder string, limit, offset int) []TeamWithDetails {
|
||||
var teams []TeamWithDetails
|
||||
|
||||
query := `
|
||||
SELECT t.team_id, t.team_lead_id,
|
||||
COALESCE(lead.first_name || ' ' || lead.last_name, 'No Lead') as team_lead_name,
|
||||
t.volunteer_id,
|
||||
COALESCE(vol.first_name || ' ' || vol.last_name, 'No Volunteer') as volunteer_name,
|
||||
t.created_at
|
||||
FROM team t
|
||||
LEFT JOIN users lead ON t.team_lead_id = lead.user_id
|
||||
LEFT JOIN users vol ON t.volunteer_id = vol.user_id
|
||||
WHERE 1=1`
|
||||
|
||||
var args []interface{}
|
||||
argCount := 0
|
||||
|
||||
if searchQuery != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND (LOWER(lead.first_name) LIKE LOWER($%d) OR LOWER(lead.last_name) LIKE LOWER($%d) OR LOWER(vol.first_name) LIKE LOWER($%d) OR LOWER(vol.last_name) LIKE LOWER($%d))`, argCount, argCount, argCount, argCount)
|
||||
args = append(args, "%"+searchQuery+"%")
|
||||
}
|
||||
|
||||
if dateFrom != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND t.created_at >= $%d`, argCount)
|
||||
args = append(args, dateFrom)
|
||||
}
|
||||
|
||||
if dateTo != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND t.created_at <= $%d`, argCount)
|
||||
args = append(args, dateTo+" 23:59:59")
|
||||
}
|
||||
|
||||
validSortColumns := map[string]bool{"created_at": true, "team_id": true}
|
||||
if !validSortColumns[sortBy] {
|
||||
sortBy = "created_at"
|
||||
}
|
||||
if sortOrder != "asc" && sortOrder != "desc" {
|
||||
sortOrder = "desc"
|
||||
}
|
||||
query += fmt.Sprintf(` ORDER BY t.%s %s`, sortBy, strings.ToUpper(sortOrder))
|
||||
|
||||
argCount++
|
||||
query += fmt.Sprintf(` LIMIT $%d`, argCount)
|
||||
args = append(args, limit)
|
||||
argCount++
|
||||
query += fmt.Sprintf(` OFFSET $%d`, argCount)
|
||||
args = append(args, offset)
|
||||
|
||||
rows, err := models.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
log.Println("Error searching teams:", err)
|
||||
return teams
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var team TeamWithDetails
|
||||
err := rows.Scan(&team.TeamID, &team.TeamLeadID, &team.TeamLeadName, &team.VolunteerID, &team.VolunteerName, &team.CreatedAt)
|
||||
if err != nil {
|
||||
log.Println("Error scanning team:", err)
|
||||
continue
|
||||
}
|
||||
teams = append(teams, team)
|
||||
}
|
||||
|
||||
return teams
|
||||
}
|
||||
|
||||
// Helper functions for counting total records
|
||||
func countUsers(searchQuery, roleFilter, dateFrom, dateTo string) int {
|
||||
query := `SELECT COUNT(*) FROM users u WHERE 1=1`
|
||||
var args []interface{}
|
||||
argCount := 0
|
||||
|
||||
if searchQuery != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND (LOWER(u.first_name) LIKE LOWER($%d) OR LOWER(u.last_name) LIKE LOWER($%d) OR LOWER(u.email) LIKE LOWER($%d))`, argCount, argCount, argCount)
|
||||
args = append(args, "%"+searchQuery+"%")
|
||||
}
|
||||
|
||||
if roleFilter != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND u.role_id = $%d`, argCount)
|
||||
roleID, _ := strconv.Atoi(roleFilter)
|
||||
args = append(args, roleID)
|
||||
}
|
||||
|
||||
if dateFrom != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND u.created_at >= $%d`, argCount)
|
||||
args = append(args, dateFrom)
|
||||
}
|
||||
|
||||
if dateTo != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND u.created_at <= $%d`, argCount)
|
||||
args = append(args, dateTo+" 23:59:59")
|
||||
}
|
||||
|
||||
var count int
|
||||
err := models.DB.QueryRow(query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
log.Println("Error counting users:", err)
|
||||
return 0
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func countPolls(searchQuery, statusFilter, dateFrom, dateTo string) int {
|
||||
query := `SELECT COUNT(*) FROM poll p WHERE 1=1`
|
||||
var args []interface{}
|
||||
argCount := 0
|
||||
|
||||
if searchQuery != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND (LOWER(p.poll_title) LIKE LOWER($%d) OR LOWER(p.poll_description) LIKE LOWER($%d))`, argCount, argCount)
|
||||
args = append(args, "%"+searchQuery+"%")
|
||||
}
|
||||
|
||||
if statusFilter == "active" {
|
||||
query += ` AND p.is_active = true`
|
||||
} else if statusFilter == "inactive" {
|
||||
query += ` AND p.is_active = false`
|
||||
}
|
||||
|
||||
if dateFrom != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND p.created_at >= $%d`, argCount)
|
||||
args = append(args, dateFrom)
|
||||
}
|
||||
|
||||
if dateTo != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND p.created_at <= $%d`, argCount)
|
||||
args = append(args, dateTo+" 23:59:59")
|
||||
}
|
||||
|
||||
var count int
|
||||
err := models.DB.QueryRow(query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
log.Println("Error counting polls:", err)
|
||||
return 0
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func countAddresses(searchQuery, dateFrom, dateTo string) int {
|
||||
query := `SELECT COUNT(*) FROM address_database WHERE 1=1`
|
||||
var args []interface{}
|
||||
argCount := 0
|
||||
|
||||
if searchQuery != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND (LOWER(address) LIKE LOWER($%d) OR LOWER(street_name) LIKE LOWER($%d) OR house_number LIKE $%d)`, argCount, argCount, argCount)
|
||||
args = append(args, "%"+searchQuery+"%")
|
||||
}
|
||||
|
||||
if dateFrom != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND created_at >= $%d`, argCount)
|
||||
args = append(args, dateFrom)
|
||||
}
|
||||
|
||||
if dateTo != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(` AND created_at <= $%d`, argCount)
|
||||
args = append(args, dateTo+" 23:59:59")
|
||||
}
|
||||
|
||||
var count int
|
||||
err := models.DB.QueryRow(query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
log.Println("Error counting addresses:", err)
|
||||
return 0
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// ExportReportHandler handles CSV export of filtered data
|
||||
func ExportReportHandler(w http.ResponseWriter, r *http.Request) {
|
||||
role := r.Context().Value("user_role").(int)
|
||||
if role != 1 { // Admin only
|
||||
http.Error(w, "Unauthorized", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
searchType := r.URL.Query().Get("search_type")
|
||||
// Get all the same filter parameters
|
||||
searchQuery := r.URL.Query().Get("search_query")
|
||||
dateFrom := r.URL.Query().Get("date_from")
|
||||
dateTo := r.URL.Query().Get("date_to")
|
||||
roleFilter := r.URL.Query().Get("role_filter")
|
||||
statusFilter := r.URL.Query().Get("status_filter")
|
||||
|
||||
w.Header().Set("Content-Type", "text/csv")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s_report_%s.csv\"", searchType, time.Now().Format("2006-01-02")))
|
||||
|
||||
switch searchType {
|
||||
case "users":
|
||||
users := searchUsers(searchQuery, roleFilter, dateFrom, dateTo, "created_at", "desc", 10000, 0) // Get all for export
|
||||
w.Write([]byte("User ID,First Name,Last Name,Email,Phone,Role ID,Admin Code,Created At\n"))
|
||||
for _, user := range users {
|
||||
line := fmt.Sprintf("%d,%s,%s,%s,%s,%d,%s,%s\n",
|
||||
user.UserID, user.FirstName, user.LastName, user.Email, user.Phone, user.RoleID, user.AdminCode, user.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
w.Write([]byte(line))
|
||||
}
|
||||
case "polls":
|
||||
polls := searchPolls(searchQuery, statusFilter, dateFrom, dateTo, "created_at", "desc", 10000, 0)
|
||||
w.Write([]byte("Poll ID,Author,Address,Title,Description,Active,Amount Donated,Created At\n"))
|
||||
for _, poll := range polls {
|
||||
line := fmt.Sprintf("%d,%s,%s,%s,%s,%t,%.2f,%s\n",
|
||||
poll.PollID, poll.AuthorName, poll.Address, poll.PollTitle, poll.PollDescription, poll.IsActive, poll.AmountDonated, poll.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
w.Write([]byte(line))
|
||||
}
|
||||
case "appointments":
|
||||
appointments := searchAppointments(searchQuery, dateFrom, dateTo, "appointment_date", "desc", 10000, 0)
|
||||
w.Write([]byte("Schedule ID,User,Address,Date,Time,Created At\n"))
|
||||
for _, apt := range appointments {
|
||||
timeStr := ""
|
||||
if !apt.AppointmentTime.IsZero() {
|
||||
timeStr = apt.AppointmentTime.Format("15:04:05")
|
||||
}
|
||||
line := fmt.Sprintf("%d,%s,%s,%s,%s,%s\n",
|
||||
apt.SchedID, apt.UserName, apt.Address, apt.AppointmentDate.Format("2006-01-02"), timeStr, apt.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
w.Write([]byte(line))
|
||||
}
|
||||
case "addresses":
|
||||
addresses := searchAddresses(searchQuery, dateFrom, dateTo, "created_at", "desc", 10000, 0)
|
||||
w.Write([]byte("Address ID,Address,Street Name,Street Type,Quadrant,House Number,Alpha,Longitude,Latitude,Visited,Created At\n"))
|
||||
for _, addr := range addresses {
|
||||
line := fmt.Sprintf("%d,%s,%s,%s,%s,%s,%s,%.6f,%.6f,%t,%s\n",
|
||||
addr.AddressID, addr.Address, addr.StreetName, addr.StreetType, addr.StreetQuadrant, addr.HouseNumber, addr.HouseAlpha,
|
||||
addr.Longitude, addr.Latitude, addr.VisitedValidated, addr.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
w.Write([]byte(line))
|
||||
}
|
||||
case "teams":
|
||||
teams := searchTeams(searchQuery, dateFrom, dateTo, "created_at", "desc", 10000, 0)
|
||||
w.Write([]byte("Team ID,Team Lead,Volunteer,Created At\n"))
|
||||
for _, team := range teams {
|
||||
line := fmt.Sprintf("%d,%s,%s,%s\n",
|
||||
team.TeamID, team.TeamLeadName, team.VolunteerName, team.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
w.Write([]byte(line))
|
||||
}
|
||||
default:
|
||||
http.Error(w, "Invalid export type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ReportStatsHandler provides JSON API for dashboard statistics
|
||||
func ReportStatsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
role := r.Context().Value("user_role").(int)
|
||||
if role != 1 { // Admin only
|
||||
http.Error(w, "Unauthorized", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
stats := make(map[string]interface{})
|
||||
|
||||
// Get total counts
|
||||
var totalUsers, totalPolls, totalAddresses, totalAppointments, totalTeams int
|
||||
var activePolls, inactivePolls int
|
||||
var visitedAddresses, unvisitedAddresses int
|
||||
|
||||
// Total users
|
||||
models.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&totalUsers)
|
||||
|
||||
// Total and active/inactive polls
|
||||
models.DB.QueryRow("SELECT COUNT(*) FROM poll").Scan(&totalPolls)
|
||||
models.DB.QueryRow("SELECT COUNT(*) FROM poll WHERE is_active = true").Scan(&activePolls)
|
||||
models.DB.QueryRow("SELECT COUNT(*) FROM poll WHERE is_active = false").Scan(&inactivePolls)
|
||||
|
||||
// Total and visited/unvisited addresses
|
||||
models.DB.QueryRow("SELECT COUNT(*) FROM address_database").Scan(&totalAddresses)
|
||||
models.DB.QueryRow("SELECT COUNT(*) FROM address_database WHERE visited_validated = true").Scan(&visitedAddresses)
|
||||
models.DB.QueryRow("SELECT COUNT(*) FROM address_database WHERE visited_validated = false").Scan(&unvisitedAddresses)
|
||||
|
||||
// Total appointments and teams
|
||||
models.DB.QueryRow("SELECT COUNT(*) FROM appointment").Scan(&totalAppointments)
|
||||
models.DB.QueryRow("SELECT COUNT(*) FROM team").Scan(&totalTeams)
|
||||
|
||||
// Recent activity (last 30 days)
|
||||
var recentUsers, recentPolls, recentAppointments int
|
||||
models.DB.QueryRow("SELECT COUNT(*) FROM users WHERE created_at >= NOW() - INTERVAL '30 days'").Scan(&recentUsers)
|
||||
models.DB.QueryRow("SELECT COUNT(*) FROM poll WHERE created_at >= NOW() - INTERVAL '30 days'").Scan(&recentPolls)
|
||||
models.DB.QueryRow("SELECT COUNT(*) FROM appointment WHERE created_at >= NOW() - INTERVAL '30 days'").Scan(&recentAppointments)
|
||||
|
||||
// Total donations
|
||||
var totalDonations float64
|
||||
models.DB.QueryRow("SELECT COALESCE(SUM(amount_donated), 0) FROM poll").Scan(&totalDonations)
|
||||
|
||||
stats["totals"] = map[string]int{
|
||||
"users": totalUsers,
|
||||
"polls": totalPolls,
|
||||
"addresses": totalAddresses,
|
||||
"appointments": totalAppointments,
|
||||
"teams": totalTeams,
|
||||
}
|
||||
|
||||
stats["poll_breakdown"] = map[string]int{
|
||||
"active": activePolls,
|
||||
"inactive": inactivePolls,
|
||||
}
|
||||
|
||||
stats["address_breakdown"] = map[string]int{
|
||||
"visited": visitedAddresses,
|
||||
"unvisited": unvisitedAddresses,
|
||||
}
|
||||
|
||||
stats["recent_activity"] = map[string]int{
|
||||
"users": recentUsers,
|
||||
"polls": recentPolls,
|
||||
"appointments": recentAppointments,
|
||||
}
|
||||
|
||||
stats["total_donations"] = totalDonations
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// utils.WriteJSON(w, stats)
|
||||
}
|
||||
|
||||
// Advanced search handler for complex queries
|
||||
func AdvancedSearchHandler(w http.ResponseWriter, r *http.Request) {
|
||||
role := r.Context().Value("user_role").(int)
|
||||
if role != 1 { // Admin only
|
||||
http.Error(w, "Unauthorized", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Build complex query based on multiple criteria
|
||||
searchCriteria := map[string]string{
|
||||
"entity": r.FormValue("entity"), // users, polls, appointments, etc.
|
||||
"text_search": r.FormValue("text_search"),
|
||||
"date_from": r.FormValue("date_from"),
|
||||
"date_to": r.FormValue("date_to"),
|
||||
"role_filter": r.FormValue("role_filter"),
|
||||
"status_filter": r.FormValue("status_filter"),
|
||||
"location_filter": r.FormValue("location_filter"), // address-based filtering
|
||||
"amount_min": r.FormValue("amount_min"), // for polls with donations
|
||||
"amount_max": r.FormValue("amount_max"),
|
||||
"sort_by": r.FormValue("sort_by"),
|
||||
"sort_order": r.FormValue("sort_order"),
|
||||
}
|
||||
|
||||
// Redirect with query parameters
|
||||
redirectURL := "/reports?"
|
||||
params := []string{}
|
||||
for key, value := range searchCriteria {
|
||||
if value != "" {
|
||||
params = append(params, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
}
|
||||
redirectURL += strings.Join(params, "&")
|
||||
|
||||
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
|
||||
func VolunteerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Replace this with actual session/jwt extraction
|
||||
currentAdminID := r.Context().Value("user_id").(int)
|
||||
username,_ := models.GetCurrentUserName(r)
|
||||
|
||||
@@ -135,11 +134,3 @@ func EditVolunteerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/volunteers", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
//assign volunterr the title of team_leader
|
||||
//Team View
|
||||
//edit volnteer data
|
||||
//
|
||||
|
||||
@@ -58,7 +58,7 @@ func createJWTToken(userID, role int) (string, time.Time, error) {
|
||||
// Get individual components from environment variables
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
|
||||
var jwtKey = []byte(jwtSecret) //TODO: Move to env/config
|
||||
var jwtKey = []byte(jwtSecret)
|
||||
|
||||
|
||||
expirationTime := time.Now().Add(12 * time.Hour)
|
||||
|
||||
@@ -20,6 +20,46 @@
|
||||
<div class="min-h-screen w-full flex flex-col">
|
||||
<!-- Main Dashboard Content -->
|
||||
<div class="w-full">
|
||||
<!-- Full Width Container -->
|
||||
<div class="min-h-screen w-full flex flex-col">
|
||||
<!-- Top Navigation Bar -->
|
||||
<div class="bg-white border-b border-gray-200 w-full">
|
||||
<div class="px-8 py-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-blue-600 flex items-center justify-center">
|
||||
<i class="fas fa-chart-bar text-white text-sm"></i>
|
||||
</div>
|
||||
<span class="text-xl font-semibold text-gray-900">
|
||||
Dashboard Overview
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
class="px-6 py-2.5 bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<i class="fas fa-download mr-2"></i>Export Data
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-2.5 bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors"
|
||||
onclick="window.location.href='/addresses/upload-csv'"
|
||||
>
|
||||
<i class="fas fa-upload mr-2"></i>Import Data
|
||||
</button>
|
||||
<!-- <button
|
||||
class="px-6 py-2.5 border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<i class="fas fa-filter mr-2"></i>Filter
|
||||
</button> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Stats Grid - Full Width -->
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 bg-white border-b border-gray-200"
|
||||
|
||||
831
app/internal/templates/reports.html
Normal file
831
app/internal/templates/reports.html
Normal file
@@ -0,0 +1,831 @@
|
||||
{{ define "content" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{.Title}}</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<!-- Full Width Container -->
|
||||
<div class="min-h-screen w-full flex flex-col">
|
||||
<!-- Main Reports Content -->
|
||||
<div class="w-full">
|
||||
<!-- Full Width Container -->
|
||||
<div class="min-h-screen w-full flex flex-col">
|
||||
<!-- Top Navigation Bar -->
|
||||
<div class="bg-white border-b border-gray-200 w-full">
|
||||
<div class="px-8 py-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-blue-600 flex items-center justify-center">
|
||||
<i class="fas fa-chart-line text-white text-sm"></i>
|
||||
</div>
|
||||
<span class="text-xl font-semibold text-gray-900">
|
||||
Reports & Analytics
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
{{if .SearchType}}
|
||||
<button onclick="exportData()" class="px-6 py-2.5 bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors">
|
||||
<i class="fas fa-download mr-2"></i>Export CSV
|
||||
</button>
|
||||
{{end}}
|
||||
<button onclick="toggleAdvancedSearch()" class="px-6 py-2.5 bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 transition-colors">
|
||||
<i class="fas fa-search-plus mr-2"></i>Advanced Search
|
||||
</button>
|
||||
<button onclick="printReport()" class="px-6 py-2.5 border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-print mr-2"></i>Print Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid - Full Width -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 bg-white border-b border-gray-200">
|
||||
<!-- Total Users -->
|
||||
<div class="border-r border-gray-200 p-8 hover:bg-gray-50 transition-colors cursor-pointer" onclick="applyQuickFilter('users', {})">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-blue-50 flex items-center justify-center">
|
||||
<i class="fas fa-users text-blue-600 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">Total Users</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{.ReportData.TotalUsers}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Polls -->
|
||||
<div class="border-r border-gray-200 p-8 hover:bg-gray-50 transition-colors cursor-pointer" onclick="applyQuickFilter('polls', {})">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-blue-50 flex items-center justify-center">
|
||||
<i class="fas fa-poll text-blue-600 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">Total Polls</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{.ReportData.TotalPolls}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Addresses -->
|
||||
<div class="border-r border-gray-200 p-8 hover:bg-gray-50 transition-colors cursor-pointer" onclick="applyQuickFilter('addresses', {})">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-blue-50 flex items-center justify-center">
|
||||
<i class="fas fa-map-marker-alt text-blue-600 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">Total Addresses</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{.ReportData.TotalAddresses}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Appointments -->
|
||||
<div class="p-8 hover:bg-gray-50 transition-colors cursor-pointer" onclick="applyQuickFilter('appointments', {})">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-blue-50 flex items-center justify-center">
|
||||
<i class="fas fa-calendar text-blue-600 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">Appointments</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{len .ReportData.Appointments}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Section - Full Width -->
|
||||
<div class="bg-white w-full">
|
||||
<div class="px-8 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Search & Filter Data</h3>
|
||||
|
||||
<form method="GET" action="/reports" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Search Type -->
|
||||
<div>
|
||||
<label for="search_type" class="block text-sm font-medium text-gray-700 mb-2">Search In</label>
|
||||
<select name="search_type" id="search_type" class="w-full px-3 py-2 border border-gray-300 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="" {{if eq .SearchType ""}}selected{{end}}>All Data</option>
|
||||
<option value="users" {{if eq .SearchType "users"}}selected{{end}}>Users</option>
|
||||
<option value="polls" {{if eq .SearchType "polls"}}selected{{end}}>Polls</option>
|
||||
<option value="appointments" {{if eq .SearchType "appointments"}}selected{{end}}>Appointments</option>
|
||||
<option value="addresses" {{if eq .SearchType "addresses"}}selected{{end}}>Addresses</option>
|
||||
<option value="teams" {{if eq .SearchType "teams"}}selected{{end}}>Teams</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Search Query -->
|
||||
<div>
|
||||
<label for="search_query" class="block text-sm font-medium text-gray-700 mb-2">Search Term</label>
|
||||
<input type="text" name="search_query" id="search_query" value="{{.SearchQuery}}"
|
||||
placeholder="Enter search term..."
|
||||
class="w-full px-3 py-2 border border-gray-300 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- Role Filter -->
|
||||
<div>
|
||||
<label for="role_filter" class="block text-sm font-medium text-gray-700 mb-2">Role Filter</label>
|
||||
<select name="role_filter" id="role_filter" class="w-full px-3 py-2 border border-gray-300 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">All Roles</option>
|
||||
<option value="1" {{if eq .RoleFilter "1"}}selected{{end}}>Admin</option>
|
||||
<option value="2" {{if eq .RoleFilter "2"}}selected{{end}}>Volunteer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<!-- Date Range -->
|
||||
<div>
|
||||
<label for="date_from" class="block text-sm font-medium text-gray-700 mb-2">Date From</label>
|
||||
<input type="date" name="date_from" id="date_from" value="{{.DateFrom}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="date_to" class="block text-sm font-medium text-gray-700 mb-2">Date To</label>
|
||||
<input type="date" name="date_to" id="date_to" value="{{.DateTo}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div>
|
||||
<label for="status_filter" class="block text-sm font-medium text-gray-700 mb-2">Status Filter</label>
|
||||
<select name="status_filter" id="status_filter" class="w-full px-3 py-2 border border-gray-300 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">All Status</option>
|
||||
<option value="active" {{if eq .StatusFilter "active"}}selected{{end}}>Active</option>
|
||||
<option value="inactive" {{if eq .StatusFilter "inactive"}}selected{{end}}>Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Sort Options -->
|
||||
<div>
|
||||
<label for="sort_by" class="block text-sm font-medium text-gray-700 mb-2">Sort By</label>
|
||||
<select name="sort_by" id="sort_by" class="w-full px-3 py-2 border border-gray-300 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="created_at" {{if eq .SortBy "created_at"}}selected{{end}}>Created Date</option>
|
||||
<option value="updated_at" {{if eq .SortBy "updated_at"}}selected{{end}}>Updated Date</option>
|
||||
<option value="first_name" {{if eq .SortBy "first_name"}}selected{{end}}>First Name</option>
|
||||
<option value="last_name" {{if eq .SortBy "last_name"}}selected{{end}}>Last Name</option>
|
||||
<option value="email" {{if eq .SortBy "email"}}selected{{end}}>Email</option>
|
||||
<option value="poll_title" {{if eq .SortBy "poll_title"}}selected{{end}}>Poll Title</option>
|
||||
<option value="amount_donated" {{if eq .SortBy "amount_donated"}}selected{{end}}>Amount Donated</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<button type="submit" class="px-6 py-2.5 bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-search mr-2"></i>Search
|
||||
</button>
|
||||
|
||||
<a href="/reports" class="px-6 py-2.5 bg-gray-600 text-white text-sm font-medium hover:bg-gray-700 transition-colors">
|
||||
<i class="fas fa-times mr-2"></i>Clear Filters
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section - Full Width -->
|
||||
{{if eq .SearchType "users"}}
|
||||
<div class="bg-white border-t border-gray-200 w-full">
|
||||
<div class="px-8 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Users Results ({{.ReportData.TotalUsers}} total)</h3>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200">
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">User</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Email</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Phone</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Role</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Admin Code</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{range .ReportData.Users}}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="py-4">
|
||||
<div class="text-sm font-medium text-gray-900">{{.FirstName}} {{.LastName}}</div>
|
||||
<div class="text-sm text-gray-500">ID: {{.UserID}}</div>
|
||||
</td>
|
||||
<td class="py-4 text-sm text-gray-900">{{.Email}}</td>
|
||||
<td class="py-4 text-sm text-gray-900">{{.Phone}}</td>
|
||||
<td class="py-4">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full {{if eq .RoleID 1}}bg-red-100 text-red-800{{else}}bg-green-100 text-green-800{{end}}">
|
||||
{{if eq .RoleID 1}}Admin{{else}}Volunteer{{end}}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 text-sm text-gray-900">{{.AdminCode}}</td>
|
||||
<td class="py-4 text-sm text-gray-500">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{else if eq .SearchType "polls"}}
|
||||
<div class="bg-white border-t border-gray-200 w-full">
|
||||
<div class="px-8 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Polls Results ({{.ReportData.TotalPolls}} total)</h3>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200">
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Poll</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Author</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Address</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Status</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Donated</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{range .ReportData.Polls}}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="py-4">
|
||||
<div class="text-sm font-medium text-gray-900">{{.PollTitle}}</div>
|
||||
<div class="text-sm text-gray-500">{{.PollDescription}}</div>
|
||||
</td>
|
||||
<td class="py-4 text-sm text-gray-900">{{.AuthorName}}</td>
|
||||
<td class="py-4 text-sm text-gray-900">{{.Address}}</td>
|
||||
<td class="py-4">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full {{if .IsActive}}bg-green-100 text-green-800{{else}}bg-red-100 text-red-800{{end}}">
|
||||
{{if .IsActive}}Active{{else}}Inactive{{end}}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 text-sm text-gray-900">${{printf "%.2f" .AmountDonated}}</td>
|
||||
<td class="py-4 text-sm text-gray-500">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{else if eq .SearchType "appointments"}}
|
||||
<div class="bg-white border-t border-gray-200 w-full">
|
||||
<div class="px-8 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Appointments Results ({{len .ReportData.Appointments}} total)</h3>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200">
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">User</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Address</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Date</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Time</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{range .ReportData.Appointments}}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="py-4 text-sm font-medium text-gray-900">{{.UserName}}</td>
|
||||
<td class="py-4 text-sm text-gray-900">{{.Address}}</td>
|
||||
<td class="py-4 text-sm text-gray-900">{{.AppointmentDate.Format "2006-01-02"}}</td>
|
||||
<td class="py-4 text-sm text-gray-900">{{.AppointmentTime.Format "15:04"}}</td>
|
||||
<td class="py-4 text-sm text-gray-500">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{else if eq .SearchType "addresses"}}
|
||||
<div class="bg-white border-t border-gray-200 w-full">
|
||||
<div class="px-8 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Addresses Results ({{.ReportData.TotalAddresses}} total)</h3>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200">
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Address</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Street Details</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Coordinates</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Visited</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{range .ReportData.Addresses}}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="py-4">
|
||||
<div class="text-sm font-medium text-gray-900">{{.Address}}</div>
|
||||
<div class="text-sm text-gray-500">{{.HouseNumber}}{{.HouseAlpha}}</div>
|
||||
</td>
|
||||
<td class="py-4">
|
||||
<div class="text-sm text-gray-900">{{.StreetName}} {{.StreetType}}</div>
|
||||
<div class="text-sm text-gray-500">{{.StreetQuadrant}}</div>
|
||||
</td>
|
||||
<td class="py-4 text-sm text-gray-900">{{printf "%.6f" .Latitude}}, {{printf "%.6f" .Longitude}}</td>
|
||||
<td class="py-4">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full {{if .VisitedValidated}}bg-green-100 text-green-800{{else}}bg-red-100 text-red-800{{end}}">
|
||||
{{if .VisitedValidated}}Visited{{else}}Not Visited{{end}}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 text-sm text-gray-500">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{else if eq .SearchType "teams"}}
|
||||
<div class="bg-white border-t border-gray-200 w-full">
|
||||
<div class="px-8 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Teams Results ({{len .ReportData.Teams}} total)</h3>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200">
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Team ID</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Team Lead</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Volunteer</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Created</th>
|
||||
<th class="text-left py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{range .ReportData.Teams}}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="py-4 text-sm font-medium text-gray-900">{{.TeamID}}</td>
|
||||
<td class="py-4 text-sm text-gray-900">{{.TeamLeadName}}</td>
|
||||
<td class="py-4 text-sm text-gray-900">{{.VolunteerName}}</td>
|
||||
<td class="py-4 text-sm text-gray-500">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td class="py-4 text-sm font-medium">
|
||||
<a href="/teams/{{.TeamID}}" class="text-blue-600 hover:text-blue-800">View Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{else}}
|
||||
<!-- Summary Dashboard View -->
|
||||
<div class="bg-white border-t border-gray-200 w-full">
|
||||
<div class="px-8 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Data Overview</h3>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Recent Users -->
|
||||
<div class="border border-gray-200">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<h4 class="text-base font-medium text-gray-900">Recent Users</h4>
|
||||
<a href="/reports?search_type=users" class="text-blue-600 hover:text-blue-800 text-sm">View All</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{{if .ReportData.Users}}
|
||||
<div class="space-y-4">
|
||||
{{range .ReportData.Users}}
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">{{.FirstName}} {{.LastName}}</div>
|
||||
<div class="text-xs text-gray-500">{{.Email}}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-gray-500">{{.CreatedAt.Format "Jan 02, 2006"}}</div>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full {{if eq .RoleID 1}}bg-red-100 text-red-800{{else}}bg-green-100 text-green-800{{end}}">
|
||||
{{if eq .RoleID 1}}Admin{{else}}Volunteer{{end}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 text-center py-8">No users found</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Polls -->
|
||||
<div class="border border-gray-200">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<h4 class="text-base font-medium text-gray-900">Recent Polls</h4>
|
||||
<a href="/reports?search_type=polls" class="text-blue-600 hover:text-blue-800 text-sm">View All</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{{if .ReportData.Polls}}
|
||||
<div class="space-y-4">
|
||||
{{range .ReportData.Polls}}
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">{{.PollTitle}}</div>
|
||||
<div class="text-xs text-gray-500">by {{.AuthorName}}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-gray-500">{{.CreatedAt.Format "Jan 02, 2006"}}</div>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full {{if .IsActive}}bg-green-100 text-green-800{{else}}bg-red-100 text-red-800{{end}}">
|
||||
{{if .IsActive}}Active{{else}}Inactive{{end}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 text-center py-8">No polls found</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Appointments -->
|
||||
<div class="border border-gray-200">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<h4 class="text-base font-medium text-gray-900">Recent Appointments</h4>
|
||||
<a href="/reports?search_type=appointments" class="text-blue-600 hover:text-blue-800 text-sm">View All</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{{if .ReportData.Appointments}}
|
||||
<div class="space-y-4">
|
||||
{{range .ReportData.Appointments}}
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">{{.UserName}}</div>
|
||||
<div class="text-xs text-gray-500">{{.Address}}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-gray-900">{{.AppointmentDate.Format "Jan 02, 2006"}}</div>
|
||||
<div class="text-xs text-gray-500">{{.AppointmentTime.Format "15:04"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 text-center py-8">No appointments found</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address Summary -->
|
||||
<div class="border border-gray-200">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<h4 class="text-base font-medium text-gray-900">Address Summary</h4>
|
||||
<a href="/reports?search_type=addresses" class="text-blue-600 hover:text-blue-800 text-sm">View All</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{{if .ReportData.Addresses}}
|
||||
<div class="space-y-4">
|
||||
{{range .ReportData.Addresses}}
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">{{.Address}}</div>
|
||||
<div class="text-xs text-gray-500">{{.StreetName}} {{.StreetType}}, {{.StreetQuadrant}}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full {{if .VisitedValidated}}bg-green-100 text-green-800{{else}}bg-red-100 text-red-800{{end}}">
|
||||
{{if .VisitedValidated}}Visited{{else}}Not Visited{{end}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 text-center py-8">No addresses found</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Chart Section - Full Width -->
|
||||
<div class="bg-white border-t border-gray-200 w-full">
|
||||
<div class="px-8 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Analytics Overview</h3>
|
||||
<div id="analytics_chart" class="w-full h-[400px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Pagination -->
|
||||
{{if and .SearchType (or (gt .ReportData.TotalUsers 0) (gt .ReportData.TotalPolls 0) (gt .ReportData.TotalAddresses 0))}}
|
||||
<div class="bg-white border-t border-gray-200 w-full">
|
||||
<div class="px-8 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-gray-700">
|
||||
Page {{.CurrentPage}} | {{.Limit}} per page
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{{if gt .CurrentPage 1}}
|
||||
<a href="?{{.Request.URL.RawQuery}}&page={{sub .CurrentPage 1}}" class="px-3 py-2 border border-gray-300 text-gray-700 text-sm hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
<span class="px-3 py-2 bg-blue-50 text-blue-600 text-sm font-medium border border-blue-200">
|
||||
{{.CurrentPage}}
|
||||
</span>
|
||||
|
||||
<a href="?{{.Request.URL.RawQuery}}&page={{add .CurrentPage 1}}" class="px-3 py-2 border border-gray-300 text-gray-700 text-sm hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Search Modal -->
|
||||
<div id="advancedSearchModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Advanced Search</h3>
|
||||
<button onclick="toggleAdvancedSearch()" class="text-gray-400 hover:text-gray-600 text-xl">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/reports/advanced-search" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Entity Type</label>
|
||||
<select name="entity" class="w-full px-3 py-2 border border-gray-300 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="users">Users</option>
|
||||
<option value="polls">Polls</option>
|
||||
<option value="appointments">Appointments</option>
|
||||
<option value="addresses">Addresses</option>
|
||||
<option value="teams">Teams</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Text Search</label>
|
||||
<input type="text" name="text_search" placeholder="Search in names, titles, descriptions..."
|
||||
class="w-full px-3 py-2 border border-gray-300 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Date From</label>
|
||||
<input type="date" name="date_from"
|
||||
class="w-full px-3 py-2 border border-gray-300 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Date To</label>
|
||||
<input type="date" name="date_to"
|
||||
class="w-full px-3 py-2 border border-gray-300 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Role Filter</label>
|
||||
<select name="role_filter" class="w-full px-3 py-2 border border-gray-300 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">All Roles</option>
|
||||
<option value="1">Admin</option>
|
||||
<option value="2">Volunteer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Status Filter</label>
|
||||
<select name="status_filter" class="w-full px-3 py-2 border border-gray-300 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Min Amount (Polls)</label>
|
||||
<input type="number" name="amount_min" step="0.01" placeholder="0.00"
|
||||
class="w-full px-3 py-2 border border-gray-300 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Max Amount (Polls)</label>
|
||||
<input type="number" name="amount_max" step="0.01" placeholder="1000.00"
|
||||
class="w-full px-3 py-2 border border-gray-300 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-6">
|
||||
<button type="button" onclick="toggleAdvancedSearch()" class="px-6 py-2.5 bg-gray-300 text-gray-800 text-sm font-medium hover:bg-gray-400 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-6 py-2.5 bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-search mr-2"></i>Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Advanced search modal toggle
|
||||
function toggleAdvancedSearch() {
|
||||
const modal = document.getElementById('advancedSearchModal');
|
||||
modal.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
// Print report functionality
|
||||
function printReport() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
// Quick filter application
|
||||
function applyQuickFilter(type, filter) {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('search_type', type);
|
||||
if (filter) {
|
||||
Object.keys(filter).forEach(key => {
|
||||
url.searchParams.set(key, filter[key]);
|
||||
});
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
// Export data with current filters
|
||||
function exportData() {
|
||||
const url = new URL('/reports/export', window.location.origin);
|
||||
const currentParams = new URLSearchParams(window.location.search);
|
||||
currentParams.forEach((value, key) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
// Auto-submit form when search type changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchTypeSelect = document.getElementById('search_type');
|
||||
const searchInput = document.getElementById('search_query');
|
||||
|
||||
// Update placeholder based on search type
|
||||
searchTypeSelect.addEventListener('change', function() {
|
||||
const placeholders = {
|
||||
'users': 'Search by name, email...',
|
||||
'polls': 'Search by title, description...',
|
||||
'appointments': 'Search by user name, address...',
|
||||
'addresses': 'Search by address, street name...',
|
||||
'teams': 'Search by team lead, volunteer name...',
|
||||
'': 'Enter search term...'
|
||||
};
|
||||
searchInput.placeholder = placeholders[this.value] || 'Enter search term...';
|
||||
});
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('advancedSearchModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
toggleAdvancedSearch();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Google Charts for analytics
|
||||
google.charts.load("current", { packages: ["corechart", "line"] });
|
||||
google.charts.setOnLoadCallback(drawAnalyticsChart);
|
||||
|
||||
function drawAnalyticsChart() {
|
||||
var data = new google.visualization.DataTable();
|
||||
data.addColumn("string", "Time");
|
||||
data.addColumn("number", "Users");
|
||||
data.addColumn("number", "Polls");
|
||||
data.addColumn("number", "Addresses");
|
||||
|
||||
// Sample data - you can replace this with real data from your backend
|
||||
data.addRows([
|
||||
["Jan", {{if .ReportData.TotalUsers}}{{.ReportData.TotalUsers}}{{else}}120{{end}}, {{if .ReportData.TotalPolls}}{{.ReportData.TotalPolls}}{{else}}45{{end}}, {{if .ReportData.TotalAddresses}}{{.ReportData.TotalAddresses}}{{else}}380{{end}}],
|
||||
["Feb", {{if .ReportData.TotalUsers}}{{add .ReportData.TotalUsers 50}}{{else}}180{{end}}, {{if .ReportData.TotalPolls}}{{add .ReportData.TotalPolls 15}}{{else}}62{{end}}, {{if .ReportData.TotalAddresses}}{{add .ReportData.TotalAddresses 120}}{{else}}420{{end}}],
|
||||
["Mar", {{if .ReportData.TotalUsers}}{{add .ReportData.TotalUsers 80}}{{else}}220{{end}}, {{if .ReportData.TotalPolls}}{{add .ReportData.TotalPolls 25}}{{else}}78{{end}}, {{if .ReportData.TotalAddresses}}{{add .ReportData.TotalAddresses 200}}{{else}}510{{end}}],
|
||||
["Apr", {{if .ReportData.TotalUsers}}{{add .ReportData.TotalUsers 120}}{{else}}280{{end}}, {{if .ReportData.TotalPolls}}{{add .ReportData.TotalPolls 35}}{{else}}95{{end}}, {{if .ReportData.TotalAddresses}}{{add .ReportData.TotalAddresses 280}}{{else}}650{{end}}],
|
||||
["May", {{if .ReportData.TotalUsers}}{{add .ReportData.TotalUsers 150}}{{else}}320{{end}}, {{if .ReportData.TotalPolls}}{{add .ReportData.TotalPolls 42}}{{else}}108{{end}}, {{if .ReportData.TotalAddresses}}{{add .ReportData.TotalAddresses 350}}{{else}}720{{end}}],
|
||||
["Jun", {{if .ReportData.TotalUsers}}{{add .ReportData.TotalUsers 200}}{{else}}380{{end}}, {{if .ReportData.TotalPolls}}{{add .ReportData.TotalPolls 58}}{{else}}125{{end}}, {{if .ReportData.TotalAddresses}}{{add .ReportData.TotalAddresses 420}}{{else}}800{{end}}],
|
||||
]);
|
||||
|
||||
var options = {
|
||||
title: "System Growth Over Time",
|
||||
backgroundColor: "transparent",
|
||||
hAxis: { title: "Month" },
|
||||
vAxis: { title: "Count" },
|
||||
colors: ["#3B82F6", "#10B981", "#F59E0B"],
|
||||
chartArea: {
|
||||
left: 60,
|
||||
top: 40,
|
||||
width: "90%",
|
||||
height: "70%",
|
||||
},
|
||||
legend: { position: "top", alignment: "center" },
|
||||
lineWidth: 3,
|
||||
pointSize: 5,
|
||||
};
|
||||
|
||||
var chart = new google.visualization.LineChart(
|
||||
document.getElementById("analytics_chart")
|
||||
);
|
||||
chart.draw(data, options);
|
||||
}
|
||||
|
||||
function updateChart(type) {
|
||||
drawAnalyticsChart();
|
||||
}
|
||||
|
||||
// Real-time search functionality
|
||||
let searchTimeout;
|
||||
function handleSearchInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(function() {
|
||||
// You can implement real-time search here
|
||||
console.log('Performing search...');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Quick stats click handlers
|
||||
function focusUsers() {
|
||||
applyQuickFilter('users', {});
|
||||
}
|
||||
|
||||
function focusPolls() {
|
||||
applyQuickFilter('polls', {});
|
||||
}
|
||||
|
||||
function focusAddresses() {
|
||||
applyQuickFilter('addresses', {});
|
||||
}
|
||||
|
||||
function focusAppointments() {
|
||||
applyQuickFilter('appointments', {});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.print-only {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.border-gray-200 {
|
||||
border-color: #000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced hover effects matching dashboard style */
|
||||
.hover-highlight:hover {
|
||||
background-color: #f7fafc;
|
||||
transform: translateY(-1px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for tables */
|
||||
.overflow-x-auto::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.overflow-x-auto::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.overflow-x-auto::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.overflow-x-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
@@ -152,6 +152,8 @@ func main() {
|
||||
http.HandleFunc("/remove_assigned_address", adminMiddleware(handlers.RemoveAssignedAddressHandler))
|
||||
|
||||
http.HandleFunc("/addresses/upload-csv", adminMiddleware(handlers.CSVUploadHandler))
|
||||
http.HandleFunc("/reports", adminMiddleware(handlers.ReportHandler))
|
||||
|
||||
|
||||
|
||||
http.HandleFunc("/posts", adminMiddleware(handlers.PostsHandler))
|
||||
|
||||
Reference in New Issue
Block a user