2025-08-30 12:59:30 -06:00
|
|
|
package handlers
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"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"
|
|
|
|
|
)
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
type ReportResult struct {
|
|
|
|
|
Columns []string `json:"columns"`
|
|
|
|
|
Rows [][]interface{} `json:"rows"`
|
|
|
|
|
Count int `json:"count"`
|
|
|
|
|
Error string `json:"error,omitempty"`
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
type ReportDefinition struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Description string `json:"description"`
|
|
|
|
|
SQL string `json:"-"` // Don't expose SQL in JSON
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
type SummaryStats struct {
|
|
|
|
|
Label string `json:"label"`
|
|
|
|
|
Value string `json:"value"`
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Simple Reports Handler
|
|
|
|
|
func ReportsHandler(w http.ResponseWriter, r *http.Request) {
|
2025-08-30 12:59:30 -06:00
|
|
|
username, _ := models.GetCurrentUserName(r)
|
|
|
|
|
role := r.Context().Value("user_role").(int)
|
|
|
|
|
|
2025-09-01 17:32:00 -06:00
|
|
|
if role != 1 {
|
2025-08-30 12:59:30 -06:00
|
|
|
http.Error(w, "Unauthorized", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
category := r.URL.Query().Get("category")
|
|
|
|
|
reportID := r.URL.Query().Get("report")
|
|
|
|
|
dateFrom := r.URL.Query().Get("date_from")
|
|
|
|
|
dateTo := r.URL.Query().Get("date_to")
|
|
|
|
|
export := r.URL.Query().Get("export")
|
2025-09-01 17:32:00 -06:00
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Set default date range if not provided
|
|
|
|
|
if dateFrom == "" {
|
|
|
|
|
dateFrom = time.Now().AddDate(0, 0, -30).Format("2006-01-02")
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
2025-09-03 14:35:47 -06:00
|
|
|
if dateTo == "" {
|
|
|
|
|
dateTo = time.Now().Format("2006-01-02")
|
2025-09-01 17:32:00 -06:00
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
var result ReportResult
|
|
|
|
|
var reportTitle, reportDescription string
|
2025-08-30 12:59:30 -06:00
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Generate report if both category and report are selected
|
|
|
|
|
if category != "" && reportID != "" {
|
|
|
|
|
result, reportTitle, reportDescription = executeReport(category, reportID, dateFrom, dateTo)
|
2025-09-01 17:32:00 -06:00
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Handle CSV export
|
|
|
|
|
if export == "csv" {
|
|
|
|
|
exportCSV(w, result, reportTitle)
|
|
|
|
|
return
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
utils.Render(w, "reports.html", map[string]interface{}{
|
|
|
|
|
"Title": "Campaign Reports",
|
|
|
|
|
"IsAuthenticated": true,
|
|
|
|
|
"ShowAdminNav": role == 1,
|
|
|
|
|
"ShowVolunteerNav": role != 1,
|
|
|
|
|
"UserName": username,
|
2025-09-05 15:39:06 -06:00
|
|
|
"ActiveSection": "reports",
|
2025-09-03 14:35:47 -06:00
|
|
|
"Category": category,
|
|
|
|
|
"ReportID": reportID,
|
|
|
|
|
"DateFrom": dateFrom,
|
|
|
|
|
"DateTo": dateTo,
|
|
|
|
|
"AvailableReports": getReportsForCategory(category),
|
|
|
|
|
"Result": result,
|
|
|
|
|
"ReportTitle": reportTitle,
|
|
|
|
|
"ReportDescription": reportDescription,
|
|
|
|
|
"GeneratedAt": time.Now().Format("January 2, 2006 at 3:04 PM"),
|
|
|
|
|
"SummaryStats": generateSummaryStats(result),
|
|
|
|
|
})
|
2025-09-01 17:32:00 -06:00
|
|
|
}
|
2025-08-30 12:59:30 -06:00
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Execute a specific report
|
|
|
|
|
func executeReport(category, reportID, dateFrom, dateTo string) (ReportResult, string, string) {
|
|
|
|
|
report := getReportDefinition(category, reportID)
|
|
|
|
|
if report == nil {
|
|
|
|
|
return ReportResult{Error: "Report not found"}, "", ""
|
2025-09-01 17:32:00 -06:00
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Replace date placeholders in SQL
|
|
|
|
|
sql := report.SQL
|
|
|
|
|
sql = replaceDatePlaceholders(sql, dateFrom, dateTo)
|
2025-08-30 12:59:30 -06:00
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Execute the SQL query
|
|
|
|
|
rows, err := models.DB.Query(sql)
|
2025-09-01 17:32:00 -06:00
|
|
|
if err != nil {
|
2025-09-03 14:35:47 -06:00
|
|
|
log.Printf("Report SQL error: %v", err)
|
|
|
|
|
return ReportResult{Error: "Failed to execute report"}, report.Name, report.Description
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
2025-09-01 17:32:00 -06:00
|
|
|
defer rows.Close()
|
2025-08-30 12:59:30 -06:00
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Get column names
|
2025-09-01 17:32:00 -06:00
|
|
|
columns, err := rows.Columns()
|
2025-08-30 12:59:30 -06:00
|
|
|
if err != nil {
|
2025-09-03 14:35:47 -06:00
|
|
|
return ReportResult{Error: "Failed to get columns"}, report.Name, report.Description
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Process rows
|
2025-09-01 17:32:00 -06:00
|
|
|
var results [][]interface{}
|
2025-08-30 12:59:30 -06:00
|
|
|
for rows.Next() {
|
2025-09-01 17:32:00 -06:00
|
|
|
values := make([]interface{}, len(columns))
|
|
|
|
|
valuePtrs := make([]interface{}, len(columns))
|
|
|
|
|
for i := range values {
|
|
|
|
|
valuePtrs[i] = &values[i]
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
|
|
|
|
|
2025-09-01 17:32:00 -06:00
|
|
|
if err := rows.Scan(valuePtrs...); err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2025-08-30 12:59:30 -06:00
|
|
|
|
2025-09-01 17:32:00 -06:00
|
|
|
row := make([]interface{}, len(columns))
|
|
|
|
|
for i, val := range values {
|
|
|
|
|
if val == nil {
|
|
|
|
|
row[i] = ""
|
|
|
|
|
} else {
|
|
|
|
|
switch v := val.(type) {
|
|
|
|
|
case []byte:
|
|
|
|
|
row[i] = string(v)
|
|
|
|
|
case time.Time:
|
|
|
|
|
row[i] = v.Format("2006-01-02 15:04:05")
|
|
|
|
|
default:
|
|
|
|
|
row[i] = fmt.Sprintf("%v", v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
results = append(results, row)
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
return ReportResult{
|
2025-09-01 17:32:00 -06:00
|
|
|
Columns: columns,
|
|
|
|
|
Rows: results,
|
|
|
|
|
Count: len(results),
|
2025-09-03 14:35:47 -06:00
|
|
|
}, report.Name, report.Description
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Get available reports for a category
|
|
|
|
|
func getReportsForCategory(category string) []ReportDefinition {
|
|
|
|
|
allReports := getAllReportDefinitions()
|
|
|
|
|
if reports, exists := allReports[category]; exists {
|
|
|
|
|
return reports
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
2025-09-03 14:35:47 -06:00
|
|
|
return []ReportDefinition{}
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Get a specific report definition
|
|
|
|
|
func getReportDefinition(category, reportID string) *ReportDefinition {
|
|
|
|
|
reports := getReportsForCategory(category)
|
|
|
|
|
for _, report := range reports {
|
|
|
|
|
if report.ID == reportID {
|
|
|
|
|
return &report
|
2025-09-01 17:32:00 -06:00
|
|
|
}
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
2025-09-03 14:35:47 -06:00
|
|
|
return nil
|
2025-09-01 17:32:00 -06:00
|
|
|
}
|
2025-08-30 12:59:30 -06:00
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Define all available reports
|
|
|
|
|
func getAllReportDefinitions() map[string][]ReportDefinition {
|
|
|
|
|
return map[string][]ReportDefinition{
|
|
|
|
|
"users": {
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "volunteer_participation_rate",
|
|
|
|
|
Name: "Volunteer Participation Rate",
|
|
|
|
|
Description: "Poll responses and donations collected by each volunteer/team lead",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
|
|
|
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
|
|
|
|
u.email,
|
2025-09-09 10:42:24 -06:00
|
|
|
r.name as role,
|
2025-09-03 14:35:47 -06:00
|
|
|
COUNT(DISTINCT p.poll_id) as polls_created,
|
2025-09-09 10:42:24 -06:00
|
|
|
COUNT(DISTINCT pr.poll_response_id) as responses_collected,
|
|
|
|
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
|
|
|
|
COUNT(DISTINCT a.sched_id) as appointments_scheduled
|
2025-09-03 14:35:47 -06:00
|
|
|
FROM users u
|
2025-09-09 10:42:24 -06:00
|
|
|
JOIN role r ON u.role_id = r.role_id
|
2025-09-03 14:35:47 -06:00
|
|
|
LEFT JOIN poll p ON u.user_id = p.user_id AND p.created_at BETWEEN ?1 AND ?2
|
2025-09-09 10:42:24 -06:00
|
|
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
|
|
|
|
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
WHERE u.role_id IN (2, 3)
|
|
|
|
|
GROUP BY u.user_id, u.first_name, u.last_name, u.email, r.name
|
|
|
|
|
ORDER BY responses_collected DESC, total_donations DESC`,
|
2025-09-03 14:35:47 -06:00
|
|
|
},
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "top_performing_volunteers",
|
|
|
|
|
Name: "Top-Performing Volunteers & Team Leads",
|
|
|
|
|
Description: "Volunteers ranked by responses collected and donations secured",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
2025-09-09 10:42:24 -06:00
|
|
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
|
|
|
|
r.name as role,
|
|
|
|
|
COUNT(DISTINCT pr.poll_response_id) as responses_collected,
|
|
|
|
|
COALESCE(SUM(pr.question6_donation_amount), 0) as donations_secured,
|
2025-09-03 14:35:47 -06:00
|
|
|
COUNT(DISTINCT p.poll_id) as polls_created,
|
2025-09-09 10:42:24 -06:00
|
|
|
AVG(pr.question6_donation_amount) as avg_donation_per_poll
|
|
|
|
|
FROM users u
|
|
|
|
|
JOIN role r ON u.role_id = r.role_id
|
|
|
|
|
JOIN poll p ON u.user_id = p.user_id
|
|
|
|
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
|
|
|
|
WHERE u.role_id IN (2, 3) AND p.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
GROUP BY u.user_id, u.first_name, u.last_name, r.name
|
|
|
|
|
HAVING COUNT(DISTINCT pr.poll_response_id) > 0
|
|
|
|
|
ORDER BY responses_collected DESC, donations_secured DESC
|
|
|
|
|
LIMIT 20`,
|
2025-09-03 14:35:47 -06:00
|
|
|
},
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "response_donation_ratio",
|
|
|
|
|
Name: "Response-to-Donation Ratio per Volunteer",
|
|
|
|
|
Description: "Efficiency measure showing donation amount per response collected",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
2025-09-09 10:42:24 -06:00
|
|
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
|
|
|
|
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
|
|
|
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN COUNT(DISTINCT pr.poll_response_id) > 0
|
|
|
|
|
THEN COALESCE(SUM(pr.question6_donation_amount), 0) / COUNT(DISTINCT pr.poll_response_id)
|
|
|
|
|
ELSE 0
|
|
|
|
|
END as donation_per_response,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN COALESCE(SUM(pr.question6_donation_amount), 0) > 0
|
|
|
|
|
THEN COUNT(DISTINCT pr.poll_response_id) / COALESCE(SUM(pr.question6_donation_amount), 1)
|
|
|
|
|
ELSE COUNT(DISTINCT pr.poll_response_id)
|
|
|
|
|
END as responses_per_dollar
|
2025-09-03 14:35:47 -06:00
|
|
|
FROM users u
|
2025-09-09 10:42:24 -06:00
|
|
|
JOIN poll p ON u.user_id = p.user_id
|
|
|
|
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
|
|
|
|
WHERE u.role_id IN (2, 3) AND p.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
GROUP BY u.user_id, u.first_name, u.last_name
|
|
|
|
|
HAVING COUNT(DISTINCT pr.poll_response_id) > 0
|
|
|
|
|
ORDER BY donation_per_response DESC`,
|
2025-09-03 14:35:47 -06:00
|
|
|
},
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "user_address_coverage",
|
|
|
|
|
Name: "User Address Coverage",
|
|
|
|
|
Description: "Number of unique addresses covered by each volunteer/team lead",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
2025-09-09 10:42:24 -06:00
|
|
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
|
|
|
|
COUNT(DISTINCT p.address_id) as unique_addresses_polled,
|
|
|
|
|
COUNT(DISTINCT a.address_id) as unique_addresses_appointed,
|
|
|
|
|
COUNT(DISTINCT COALESCE(p.address_id, a.address_id)) as total_unique_addresses,
|
|
|
|
|
COUNT(DISTINCT pr.poll_response_id) as total_responses
|
2025-09-03 14:35:47 -06:00
|
|
|
FROM users u
|
2025-09-09 10:42:24 -06:00
|
|
|
LEFT JOIN poll p ON u.user_id = p.user_id AND p.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
|
|
|
|
WHERE u.role_id IN (2, 3)
|
|
|
|
|
GROUP BY u.user_id, u.first_name, u.last_name
|
|
|
|
|
HAVING COUNT(DISTINCT COALESCE(p.address_id, a.address_id)) > 0
|
|
|
|
|
ORDER BY total_unique_addresses DESC, total_responses DESC`,
|
2025-09-03 14:35:47 -06:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"addresses": {
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "poll_responses_by_address",
|
|
|
|
|
Name: "Total Poll Responses by Address",
|
|
|
|
|
Description: "Shows engagement hotspots - addresses with most poll responses",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
2025-09-09 10:42:24 -06:00
|
|
|
ad.address,
|
|
|
|
|
ad.postal_code,
|
|
|
|
|
ad.street_quadrant,
|
|
|
|
|
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
|
|
|
|
COUNT(DISTINCT p.poll_id) as polls_conducted,
|
|
|
|
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN COUNT(DISTINCT pr.poll_response_id) > 0
|
|
|
|
|
THEN COALESCE(SUM(pr.question6_donation_amount), 0) / COUNT(DISTINCT pr.poll_response_id)
|
|
|
|
|
ELSE 0
|
|
|
|
|
END as avg_donation_per_response
|
|
|
|
|
FROM address_database ad
|
|
|
|
|
JOIN poll p ON ad.address_id = p.address_id
|
|
|
|
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
|
|
|
|
WHERE p.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
GROUP BY ad.address_id, ad.address, ad.postal_code, ad.street_quadrant
|
|
|
|
|
ORDER BY total_responses DESC, total_donations DESC
|
2025-09-03 14:35:47 -06:00
|
|
|
LIMIT 50`,
|
|
|
|
|
},
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "donations_by_address",
|
|
|
|
|
Name: "Total Donations by Address",
|
|
|
|
|
Description: "Shows financially supportive areas - addresses with highest donations",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
2025-09-09 10:42:24 -06:00
|
|
|
ad.address,
|
|
|
|
|
ad.postal_code,
|
|
|
|
|
ad.street_quadrant,
|
|
|
|
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
|
|
|
|
COUNT(DISTINCT p.poll_id) as polls_conducted,
|
|
|
|
|
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
|
|
|
|
AVG(pr.question6_donation_amount) as avg_donation_per_poll
|
|
|
|
|
FROM address_database ad
|
|
|
|
|
JOIN poll p ON ad.address_id = p.address_id
|
|
|
|
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
|
|
|
|
WHERE p.created_at BETWEEN ?1 AND ?2 AND pr.question6_donation_amount > 0
|
|
|
|
|
GROUP BY ad.address_id, ad.address, ad.postal_code, ad.street_quadrant
|
2025-09-03 14:35:47 -06:00
|
|
|
ORDER BY total_donations DESC
|
|
|
|
|
LIMIT 50`,
|
|
|
|
|
},
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "street_level_breakdown",
|
|
|
|
|
Name: "Street-Level Breakdown (Responses & Donations)",
|
|
|
|
|
Description: "Granular view for targeting - responses and donations by street",
|
|
|
|
|
SQL: `SELECT
|
|
|
|
|
ad.street_name,
|
|
|
|
|
ad.street_type,
|
|
|
|
|
ad.street_quadrant,
|
|
|
|
|
COUNT(DISTINCT ad.address_id) as unique_addresses,
|
|
|
|
|
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
|
|
|
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
|
|
|
|
COUNT(DISTINCT p.poll_id) as polls_conducted
|
|
|
|
|
FROM address_database ad
|
|
|
|
|
LEFT JOIN poll p ON ad.address_id = p.address_id AND p.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
|
|
|
|
WHERE ad.street_name IS NOT NULL
|
|
|
|
|
GROUP BY ad.street_name, ad.street_type, ad.street_quadrant
|
|
|
|
|
HAVING COUNT(DISTINCT pr.poll_response_id) > 0 OR COALESCE(SUM(pr.question6_donation_amount), 0) > 0
|
|
|
|
|
ORDER BY total_responses DESC, total_donations DESC`,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
ID: "quadrant_summary",
|
|
|
|
|
Name: "Quadrant-Level Summary (NE, NW, SE, SW)",
|
|
|
|
|
Description: "Higher-level trend view by city quadrants",
|
|
|
|
|
SQL: `SELECT
|
|
|
|
|
COALESCE(ad.street_quadrant, 'Unknown') as quadrant,
|
|
|
|
|
COUNT(DISTINCT ad.address_id) as unique_addresses,
|
|
|
|
|
COUNT(DISTINCT p.poll_id) as polls_conducted,
|
|
|
|
|
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
|
|
|
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
|
|
|
|
AVG(pr.question6_donation_amount) as avg_donation_per_poll,
|
|
|
|
|
COUNT(DISTINCT a.sched_id) as appointments_scheduled
|
|
|
|
|
FROM address_database ad
|
|
|
|
|
LEFT JOIN poll p ON ad.address_id = p.address_id AND p.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
|
|
|
|
LEFT JOIN appointment a ON ad.address_id = a.address_id AND a.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
GROUP BY ad.street_quadrant
|
|
|
|
|
ORDER BY total_responses DESC, total_donations DESC`,
|
2025-09-03 14:35:47 -06:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"appointments": {
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "upcoming_appointments",
|
|
|
|
|
Name: "Upcoming Appointments per Volunteer/Team Lead",
|
|
|
|
|
Description: "Scheduling load - upcoming appointments by user",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
2025-09-09 10:42:24 -06:00
|
|
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
|
|
|
|
r.name as role,
|
|
|
|
|
COUNT(*) as upcoming_appointments,
|
|
|
|
|
MIN(a.appointment_date) as earliest_appointment,
|
|
|
|
|
MAX(a.appointment_date) as latest_appointment,
|
|
|
|
|
COUNT(CASE WHEN a.appointment_date = CURRENT_DATE THEN 1 END) as today_appointments,
|
|
|
|
|
COUNT(CASE WHEN a.appointment_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '7 days' THEN 1 END) as week_appointments
|
|
|
|
|
FROM appointment a
|
|
|
|
|
JOIN users u ON a.user_id = u.user_id
|
|
|
|
|
JOIN role r ON u.role_id = r.role_id
|
|
|
|
|
WHERE a.appointment_date >= CURRENT_DATE AND a.appointment_date BETWEEN ?1 AND ?2
|
|
|
|
|
GROUP BY u.user_id, u.first_name, u.last_name, r.name
|
|
|
|
|
ORDER BY upcoming_appointments DESC`,
|
2025-09-03 14:35:47 -06:00
|
|
|
},
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "missed_vs_completed",
|
|
|
|
|
Name: "Missed vs Completed Appointments",
|
|
|
|
|
Description: "Reliability metric - appointment completion rates",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
|
|
|
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
2025-09-09 10:42:24 -06:00
|
|
|
COUNT(*) as total_appointments,
|
|
|
|
|
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END) as past_appointments,
|
|
|
|
|
COUNT(CASE WHEN a.appointment_date >= CURRENT_DATE THEN 1 END) as upcoming_appointments,
|
|
|
|
|
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
|
|
|
|
|
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
|
|
|
|
AND DATE(p.created_at) = a.appointment_date
|
|
|
|
|
) THEN 1 END) as completed_appointments,
|
|
|
|
|
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE AND NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
|
|
|
|
AND DATE(p.created_at) = a.appointment_date
|
|
|
|
|
) THEN 1 END) as missed_appointments,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END) > 0
|
|
|
|
|
THEN COUNT(CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
|
|
|
|
|
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
|
|
|
|
AND DATE(p.created_at) = a.appointment_date
|
|
|
|
|
) THEN 1 END) * 100.0 / COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END)
|
|
|
|
|
ELSE 0
|
|
|
|
|
END as completion_rate_percent
|
2025-09-03 14:35:47 -06:00
|
|
|
FROM appointment a
|
|
|
|
|
JOIN users u ON a.user_id = u.user_id
|
|
|
|
|
WHERE a.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
GROUP BY u.user_id, u.first_name, u.last_name
|
2025-09-09 10:42:24 -06:00
|
|
|
ORDER BY completion_rate_percent DESC, total_appointments DESC`,
|
2025-09-03 14:35:47 -06:00
|
|
|
},
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "appointments_by_quadrant",
|
|
|
|
|
Name: "Appointments by Quadrant/Region",
|
|
|
|
|
Description: "Geographic distribution of appointments",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
2025-09-09 10:42:24 -06:00
|
|
|
COALESCE(ad.street_quadrant, 'Unknown') as quadrant,
|
|
|
|
|
COUNT(*) as total_appointments,
|
|
|
|
|
COUNT(CASE WHEN a.appointment_date >= CURRENT_DATE THEN 1 END) as upcoming,
|
|
|
|
|
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END) as past,
|
|
|
|
|
COUNT(DISTINCT a.user_id) as unique_volunteers,
|
|
|
|
|
COUNT(DISTINCT a.address_id) as unique_addresses
|
2025-09-03 14:35:47 -06:00
|
|
|
FROM appointment a
|
|
|
|
|
JOIN address_database ad ON a.address_id = ad.address_id
|
2025-09-09 10:42:24 -06:00
|
|
|
WHERE a.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
GROUP BY ad.street_quadrant
|
|
|
|
|
ORDER BY total_appointments DESC`,
|
2025-09-03 14:35:47 -06:00
|
|
|
},
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "scheduling_lead_time",
|
|
|
|
|
Name: "Average Lead Time (Scheduled vs Actual Date)",
|
|
|
|
|
Description: "Scheduling efficiency - time between scheduling and appointment",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
|
|
|
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
2025-09-09 10:42:24 -06:00
|
|
|
COUNT(*) as total_appointments,
|
|
|
|
|
AVG(a.appointment_date - DATE(a.created_at)) as avg_lead_time_days,
|
|
|
|
|
MIN(a.appointment_date - DATE(a.created_at)) as min_lead_time_days,
|
|
|
|
|
MAX(a.appointment_date - DATE(a.created_at)) as max_lead_time_days,
|
|
|
|
|
COUNT(CASE WHEN a.appointment_date - DATE(a.created_at) < 1 THEN 1 END) as same_day_appointments,
|
|
|
|
|
COUNT(CASE WHEN a.appointment_date - DATE(a.created_at) BETWEEN 1 AND 7 THEN 1 END) as week_ahead_appointments
|
2025-09-03 14:35:47 -06:00
|
|
|
FROM appointment a
|
|
|
|
|
JOIN users u ON a.user_id = u.user_id
|
2025-09-09 10:42:24 -06:00
|
|
|
WHERE a.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
GROUP BY u.user_id, u.first_name, u.last_name
|
|
|
|
|
HAVING COUNT(*) > 0
|
|
|
|
|
ORDER BY avg_lead_time_days ASC`,
|
2025-09-03 14:35:47 -06:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"polls": {
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "response_distribution",
|
|
|
|
|
Name: "Response Distribution (Yes/No/Neutral)",
|
|
|
|
|
Description: "Outcome summary - distribution of poll responses",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
|
|
|
|
CASE
|
2025-09-09 10:42:24 -06:00
|
|
|
WHEN question1_voted_before = true AND question2_vote_again = true THEN 'Previous Voter - Will Vote Again'
|
|
|
|
|
WHEN question1_voted_before = true AND question2_vote_again = false THEN 'Previous Voter - Will Not Vote Again'
|
|
|
|
|
WHEN question1_voted_before = false AND question2_vote_again = true THEN 'New Voter - Will Vote'
|
|
|
|
|
WHEN question1_voted_before = false AND question2_vote_again = false THEN 'New Voter - Will Not Vote'
|
|
|
|
|
WHEN question1_voted_before IS NULL OR question2_vote_again IS NULL THEN 'Incomplete Response'
|
|
|
|
|
END as response_category,
|
2025-09-03 14:35:47 -06:00
|
|
|
COUNT(*) as response_count,
|
2025-09-09 10:42:24 -06:00
|
|
|
COUNT(*) * 100.0 / (SELECT COUNT(*) FROM poll_response pr2
|
|
|
|
|
JOIN poll p2 ON pr2.poll_id = p2.poll_id
|
|
|
|
|
WHERE p2.created_at BETWEEN ?1 AND ?2) as percentage
|
2025-09-03 14:35:47 -06:00
|
|
|
FROM poll_response pr
|
|
|
|
|
JOIN poll p ON pr.poll_id = p.poll_id
|
|
|
|
|
WHERE p.created_at BETWEEN ?1 AND ?2
|
2025-09-09 10:42:24 -06:00
|
|
|
GROUP BY response_category
|
2025-09-03 14:35:47 -06:00
|
|
|
ORDER BY response_count DESC`,
|
|
|
|
|
},
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "average_poll_response",
|
|
|
|
|
Name: "Average Poll Response (Yes/No %)",
|
|
|
|
|
Description: "Overall sentiment - percentage breakdown of responses",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
2025-09-09 10:42:24 -06:00
|
|
|
'Previous Voters' as voter_type,
|
|
|
|
|
COUNT(*) as total_responses,
|
|
|
|
|
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) as positive_responses,
|
|
|
|
|
COUNT(CASE WHEN question2_vote_again = false THEN 1 END) as negative_responses,
|
|
|
|
|
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) * 100.0 / COUNT(*) as positive_percentage
|
2025-09-03 14:35:47 -06:00
|
|
|
FROM poll_response pr
|
|
|
|
|
JOIN poll p ON pr.poll_id = p.poll_id
|
2025-09-09 10:42:24 -06:00
|
|
|
WHERE p.created_at BETWEEN ?1 AND ?2 AND question1_voted_before = true
|
2025-09-03 14:35:47 -06:00
|
|
|
UNION ALL
|
|
|
|
|
SELECT
|
2025-09-09 10:42:24 -06:00
|
|
|
'New Voters' as voter_type,
|
|
|
|
|
COUNT(*) as total_responses,
|
|
|
|
|
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) as positive_responses,
|
|
|
|
|
COUNT(CASE WHEN question2_vote_again = false THEN 1 END) as negative_responses,
|
|
|
|
|
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) * 100.0 / COUNT(*) as positive_percentage
|
2025-09-03 14:35:47 -06:00
|
|
|
FROM poll_response pr
|
|
|
|
|
JOIN poll p ON pr.poll_id = p.poll_id
|
2025-09-09 10:42:24 -06:00
|
|
|
WHERE p.created_at BETWEEN ?1 AND ?2 AND question1_voted_before = false
|
|
|
|
|
UNION ALL
|
|
|
|
|
SELECT
|
|
|
|
|
'Overall' as voter_type,
|
|
|
|
|
COUNT(*) as total_responses,
|
|
|
|
|
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) as positive_responses,
|
|
|
|
|
COUNT(CASE WHEN question2_vote_again = false THEN 1 END) as negative_responses,
|
|
|
|
|
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) * 100.0 / COUNT(*) as positive_percentage
|
2025-09-03 14:35:47 -06:00
|
|
|
FROM poll_response pr
|
|
|
|
|
JOIN poll p ON pr.poll_id = p.poll_id
|
2025-09-09 10:42:24 -06:00
|
|
|
WHERE p.created_at BETWEEN ?1 AND ?2`,
|
2025-09-03 14:35:47 -06:00
|
|
|
},
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "donations_by_poll",
|
|
|
|
|
Name: "Donations by Poll",
|
|
|
|
|
Description: "Which polls drive donations - donation amounts per poll",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
2025-09-09 10:42:24 -06:00
|
|
|
p.poll_id,
|
|
|
|
|
u.first_name || ' ' || u.last_name as creator_name,
|
|
|
|
|
ad.address,
|
|
|
|
|
pr.question6_donation_amount,
|
|
|
|
|
COUNT(pr.poll_response_id) as response_count,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN COUNT(pr.poll_response_id) > 0
|
|
|
|
|
THEN pr.question6_donation_amount / COUNT(pr.poll_response_id)
|
|
|
|
|
ELSE 0
|
|
|
|
|
END as donation_per_response,
|
|
|
|
|
p.created_at as poll_date
|
|
|
|
|
FROM poll p
|
|
|
|
|
JOIN users u ON p.user_id = u.user_id
|
|
|
|
|
JOIN address_database ad ON p.address_id = ad.address_id
|
|
|
|
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
|
|
|
|
WHERE p.created_at BETWEEN ?1 AND ?2 AND pr.question6_donation_amount > 0
|
|
|
|
|
GROUP BY p.poll_id, u.first_name, u.last_name, ad.address, pr.question6_donation_amount, p.created_at
|
|
|
|
|
ORDER BY pr.question6_donation_amount DESC, response_count DESC`,
|
2025-09-03 14:35:47 -06:00
|
|
|
},
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "response_donation_correlation",
|
|
|
|
|
Name: "Response-to-Donation Correlation",
|
|
|
|
|
Description: "Are positive responses linked to donations?",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
2025-09-09 10:42:24 -06:00
|
|
|
CASE
|
|
|
|
|
WHEN question2_vote_again = true THEN 'Will Vote Again'
|
|
|
|
|
WHEN question2_vote_again = false THEN 'Will Not Vote Again'
|
|
|
|
|
ELSE 'No Response'
|
|
|
|
|
END as response_type,
|
|
|
|
|
COUNT(*) as response_count,
|
|
|
|
|
COUNT(CASE WHEN pr.question6_donation_amount > 0 THEN 1 END) as responses_with_donations,
|
|
|
|
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
|
|
|
|
AVG(pr.question6_donation_amount) as avg_donation,
|
|
|
|
|
COUNT(CASE WHEN pr.question6_donation_amount > 0 THEN 1 END) * 100.0 / COUNT(*) as donation_rate_percent
|
2025-09-03 14:35:47 -06:00
|
|
|
FROM poll_response pr
|
|
|
|
|
JOIN poll p ON pr.poll_id = p.poll_id
|
|
|
|
|
WHERE p.created_at BETWEEN ?1 AND ?2
|
2025-09-09 10:42:24 -06:00
|
|
|
GROUP BY question2_vote_again
|
|
|
|
|
ORDER BY total_donations DESC`,
|
2025-09-03 14:35:47 -06:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"availability": {
|
|
|
|
|
{
|
2025-09-09 10:42:24 -06:00
|
|
|
ID: "volunteer_availability_schedule",
|
|
|
|
|
Name: "Volunteer Availability by Date Range",
|
|
|
|
|
Description: "Who can work when - current volunteer availability schedules",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
|
|
|
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
2025-09-09 10:42:24 -06:00
|
|
|
u.email,
|
2025-09-03 14:35:47 -06:00
|
|
|
av.day_of_week,
|
|
|
|
|
av.start_time,
|
|
|
|
|
av.end_time,
|
2025-09-09 10:42:24 -06:00
|
|
|
EXTRACT(EPOCH FROM (av.end_time - av.start_time))/3600 as hours_available,
|
2025-09-03 14:35:47 -06:00
|
|
|
av.created_at as schedule_updated
|
2025-09-09 10:42:24 -06:00
|
|
|
FROM availability av
|
2025-09-03 14:35:47 -06:00
|
|
|
JOIN users u ON av.user_id = u.user_id
|
2025-09-09 10:42:24 -06:00
|
|
|
WHERE u.role_id IN (2, 3) AND av.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
ORDER BY u.first_name, u.last_name,
|
|
|
|
|
CASE av.day_of_week
|
|
|
|
|
WHEN 'Monday' THEN 1
|
|
|
|
|
WHEN 'Tuesday' THEN 2
|
|
|
|
|
WHEN 'Wednesday' THEN 3
|
|
|
|
|
WHEN 'Thursday' THEN 4
|
|
|
|
|
WHEN 'Friday' THEN 5
|
|
|
|
|
WHEN 'Saturday' THEN 6
|
|
|
|
|
WHEN 'Sunday' THEN 7
|
|
|
|
|
END, av.start_time`,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
ID: "volunteer_fulfillment",
|
|
|
|
|
Name: "Volunteer Fulfillment (Available vs Actually Worked)",
|
|
|
|
|
Description: "Reliability measure - comparing availability to actual appointments",
|
2025-09-03 14:35:47 -06:00
|
|
|
SQL: `SELECT
|
|
|
|
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
2025-09-09 10:42:24 -06:00
|
|
|
COUNT(DISTINCT av.availability_id) as availability_slots,
|
|
|
|
|
SUM(EXTRACT(EPOCH FROM (av.end_time - av.start_time))/3600) as total_hours_available,
|
|
|
|
|
COUNT(DISTINCT a.sched_id) as appointments_scheduled,
|
|
|
|
|
COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE THEN a.sched_id END) as past_appointments,
|
|
|
|
|
COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
|
|
|
|
|
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
|
|
|
|
AND DATE(p.created_at) = a.appointment_date
|
|
|
|
|
) THEN a.sched_id END) as completed_appointments,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE THEN a.sched_id END) > 0
|
|
|
|
|
THEN COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
|
|
|
|
|
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
|
|
|
|
AND DATE(p.created_at) = a.appointment_date
|
|
|
|
|
) THEN a.sched_id END) * 100.0 / COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE THEN a.sched_id END)
|
|
|
|
|
ELSE 0
|
|
|
|
|
END as fulfillment_rate_percent
|
|
|
|
|
FROM users u
|
|
|
|
|
LEFT JOIN availability av ON u.user_id = av.user_id AND av.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
|
|
|
|
|
WHERE u.role_id IN (2, 3)
|
|
|
|
|
GROUP BY u.user_id, u.first_name, u.last_name
|
|
|
|
|
HAVING COUNT(DISTINCT av.availability_id) > 0 OR COUNT(DISTINCT a.sched_id) > 0
|
|
|
|
|
ORDER BY fulfillment_rate_percent DESC, total_hours_available DESC`,
|
2025-09-03 14:35:47 -06:00
|
|
|
},
|
|
|
|
|
},
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Replace date placeholders in SQL
|
|
|
|
|
func replaceDatePlaceholders(sql, dateFrom, dateTo string) string {
|
|
|
|
|
sql = strings.ReplaceAll(sql, "?1", "'"+dateFrom+"'")
|
|
|
|
|
sql = strings.ReplaceAll(sql, "?2", "'"+dateTo+" 23:59:59'")
|
|
|
|
|
return sql
|
2025-09-01 17:32:00 -06:00
|
|
|
}
|
2025-08-30 12:59:30 -06:00
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Generate summary statistics
|
|
|
|
|
func generateSummaryStats(result ReportResult) []SummaryStats {
|
|
|
|
|
if len(result.Rows) == 0 {
|
|
|
|
|
return nil
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
stats := []SummaryStats{
|
|
|
|
|
{Label: "Total Records", Value: strconv.Itoa(result.Count)},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to generate additional stats based on column types
|
|
|
|
|
if len(result.Columns) > 0 {
|
|
|
|
|
// Look for numeric columns to calculate sums/averages
|
|
|
|
|
for colIdx, colName := range result.Columns {
|
|
|
|
|
if strings.Contains(strings.ToLower(colName), "count") ||
|
|
|
|
|
strings.Contains(strings.ToLower(colName), "total") ||
|
|
|
|
|
strings.Contains(strings.ToLower(colName), "amount") {
|
|
|
|
|
|
|
|
|
|
var sum float64
|
|
|
|
|
var validCount int
|
|
|
|
|
|
|
|
|
|
for _, row := range result.Rows {
|
|
|
|
|
if colIdx < len(row) {
|
|
|
|
|
if val, err := strconv.ParseFloat(fmt.Sprintf("%v", row[colIdx]), 64); err == nil {
|
|
|
|
|
sum += val
|
|
|
|
|
validCount++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if validCount > 0 {
|
|
|
|
|
stats = append(stats, SummaryStats{
|
|
|
|
|
Label: fmt.Sprintf("Total %s", strings.Title(strings.ToLower(colName))),
|
|
|
|
|
Value: fmt.Sprintf("%.2f", sum),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if validCount > 1 {
|
|
|
|
|
stats = append(stats, SummaryStats{
|
|
|
|
|
Label: fmt.Sprintf("Average %s", strings.Title(strings.ToLower(colName))),
|
|
|
|
|
Value: fmt.Sprintf("%.2f", sum/float64(validCount)),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break // Only calculate for first numeric column
|
|
|
|
|
}
|
2025-09-01 17:32:00 -06:00
|
|
|
}
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
|
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
return stats
|
|
|
|
|
}
|
2025-08-30 12:59:30 -06:00
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Export CSV
|
|
|
|
|
func exportCSV(w http.ResponseWriter, result ReportResult, reportTitle string) {
|
2025-09-01 17:32:00 -06:00
|
|
|
w.Header().Set("Content-Type", "text/csv")
|
2025-09-03 14:35:47 -06:00
|
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s_%s.csv\"",
|
|
|
|
|
strings.ReplaceAll(strings.ToLower(reportTitle), " ", "_"),
|
|
|
|
|
time.Now().Format("2006-01-02")))
|
2025-09-01 17:32:00 -06:00
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Write header
|
|
|
|
|
fmt.Fprintf(w, "%s\n", strings.Join(result.Columns, ","))
|
2025-09-01 17:32:00 -06:00
|
|
|
|
2025-09-03 14:35:47 -06:00
|
|
|
// Write data
|
2025-09-01 17:32:00 -06:00
|
|
|
for _, row := range result.Rows {
|
|
|
|
|
var csvRow []string
|
2025-09-03 14:35:47 -06:00
|
|
|
for _, cell := range row {
|
|
|
|
|
// Escape CSV values
|
|
|
|
|
cellStr := fmt.Sprintf("%v", cell)
|
|
|
|
|
if strings.Contains(cellStr, ",") || strings.Contains(cellStr, "\"") || strings.Contains(cellStr, "\n") {
|
|
|
|
|
cellStr = `"` + strings.ReplaceAll(cellStr, `"`, `""`) + `"`
|
2025-09-01 17:32:00 -06:00
|
|
|
}
|
2025-09-03 14:35:47 -06:00
|
|
|
csvRow = append(csvRow, cellStr)
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
2025-09-03 14:35:47 -06:00
|
|
|
fmt.Fprintf(w, "%s\n", strings.Join(csvRow, ","))
|
2025-08-30 12:59:30 -06:00
|
|
|
}
|
|
|
|
|
}
|