Files
Poll-system/app/internal/handlers/admin_reports.go
2025-09-09 10:42:24 -06:00

685 lines
26 KiB
Go

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"
)
type ReportResult struct {
Columns []string `json:"columns"`
Rows [][]interface{} `json:"rows"`
Count int `json:"count"`
Error string `json:"error,omitempty"`
}
type ReportDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
SQL string `json:"-"` // Don't expose SQL in JSON
}
type SummaryStats struct {
Label string `json:"label"`
Value string `json:"value"`
}
// Simple Reports Handler
func ReportsHandler(w http.ResponseWriter, r *http.Request) {
username, _ := models.GetCurrentUserName(r)
role := r.Context().Value("user_role").(int)
if role != 1 {
http.Error(w, "Unauthorized", http.StatusForbidden)
return
}
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")
// Set default date range if not provided
if dateFrom == "" {
dateFrom = time.Now().AddDate(0, 0, -30).Format("2006-01-02")
}
if dateTo == "" {
dateTo = time.Now().Format("2006-01-02")
}
var result ReportResult
var reportTitle, reportDescription string
// Generate report if both category and report are selected
if category != "" && reportID != "" {
result, reportTitle, reportDescription = executeReport(category, reportID, dateFrom, dateTo)
// Handle CSV export
if export == "csv" {
exportCSV(w, result, reportTitle)
return
}
}
utils.Render(w, "reports.html", map[string]interface{}{
"Title": "Campaign Reports",
"IsAuthenticated": true,
"ShowAdminNav": role == 1,
"ShowVolunteerNav": role != 1,
"UserName": username,
"ActiveSection": "reports",
"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),
})
}
// 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"}, "", ""
}
// Replace date placeholders in SQL
sql := report.SQL
sql = replaceDatePlaceholders(sql, dateFrom, dateTo)
// Execute the SQL query
rows, err := models.DB.Query(sql)
if err != nil {
log.Printf("Report SQL error: %v", err)
return ReportResult{Error: "Failed to execute report"}, report.Name, report.Description
}
defer rows.Close()
// Get column names
columns, err := rows.Columns()
if err != nil {
return ReportResult{Error: "Failed to get columns"}, report.Name, report.Description
}
// Process rows
var results [][]interface{}
for rows.Next() {
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
continue
}
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)
}
return ReportResult{
Columns: columns,
Rows: results,
Count: len(results),
}, report.Name, report.Description
}
// Get available reports for a category
func getReportsForCategory(category string) []ReportDefinition {
allReports := getAllReportDefinitions()
if reports, exists := allReports[category]; exists {
return reports
}
return []ReportDefinition{}
}
// Get a specific report definition
func getReportDefinition(category, reportID string) *ReportDefinition {
reports := getReportsForCategory(category)
for _, report := range reports {
if report.ID == reportID {
return &report
}
}
return nil
}
// Define all available reports
func getAllReportDefinitions() map[string][]ReportDefinition {
return map[string][]ReportDefinition{
"users": {
{
ID: "volunteer_participation_rate",
Name: "Volunteer Participation Rate",
Description: "Poll responses and donations collected by each volunteer/team lead",
SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name,
u.email,
r.name as role,
COUNT(DISTINCT p.poll_id) as polls_created,
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
FROM users u
JOIN role r ON u.role_id = r.role_id
LEFT JOIN poll p ON u.user_id = p.user_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 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`,
},
{
ID: "top_performing_volunteers",
Name: "Top-Performing Volunteers & Team Leads",
Description: "Volunteers ranked by responses collected and donations secured",
SQL: `SELECT
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,
COUNT(DISTINCT p.poll_id) as polls_created,
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`,
},
{
ID: "response_donation_ratio",
Name: "Response-to-Donation Ratio per Volunteer",
Description: "Efficiency measure showing donation amount per response collected",
SQL: `SELECT
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
FROM users u
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`,
},
{
ID: "user_address_coverage",
Name: "User Address Coverage",
Description: "Number of unique addresses covered by each volunteer/team lead",
SQL: `SELECT
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
FROM users u
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`,
},
},
"addresses": {
{
ID: "poll_responses_by_address",
Name: "Total Poll Responses by Address",
Description: "Shows engagement hotspots - addresses with most poll responses",
SQL: `SELECT
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
LIMIT 50`,
},
{
ID: "donations_by_address",
Name: "Total Donations by Address",
Description: "Shows financially supportive areas - addresses with highest donations",
SQL: `SELECT
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
ORDER BY total_donations DESC
LIMIT 50`,
},
{
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`,
},
},
"appointments": {
{
ID: "upcoming_appointments",
Name: "Upcoming Appointments per Volunteer/Team Lead",
Description: "Scheduling load - upcoming appointments by user",
SQL: `SELECT
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`,
},
{
ID: "missed_vs_completed",
Name: "Missed vs Completed Appointments",
Description: "Reliability metric - appointment completion rates",
SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name,
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
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
ORDER BY completion_rate_percent DESC, total_appointments DESC`,
},
{
ID: "appointments_by_quadrant",
Name: "Appointments by Quadrant/Region",
Description: "Geographic distribution of appointments",
SQL: `SELECT
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
FROM appointment a
JOIN address_database ad ON a.address_id = ad.address_id
WHERE a.created_at BETWEEN ?1 AND ?2
GROUP BY ad.street_quadrant
ORDER BY total_appointments DESC`,
},
{
ID: "scheduling_lead_time",
Name: "Average Lead Time (Scheduled vs Actual Date)",
Description: "Scheduling efficiency - time between scheduling and appointment",
SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name,
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
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
HAVING COUNT(*) > 0
ORDER BY avg_lead_time_days ASC`,
},
},
"polls": {
{
ID: "response_distribution",
Name: "Response Distribution (Yes/No/Neutral)",
Description: "Outcome summary - distribution of poll responses",
SQL: `SELECT
CASE
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,
COUNT(*) as response_count,
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
FROM poll_response pr
JOIN poll p ON pr.poll_id = p.poll_id
WHERE p.created_at BETWEEN ?1 AND ?2
GROUP BY response_category
ORDER BY response_count DESC`,
},
{
ID: "average_poll_response",
Name: "Average Poll Response (Yes/No %)",
Description: "Overall sentiment - percentage breakdown of responses",
SQL: `SELECT
'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
FROM poll_response pr
JOIN poll p ON pr.poll_id = p.poll_id
WHERE p.created_at BETWEEN ?1 AND ?2 AND question1_voted_before = true
UNION ALL
SELECT
'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
FROM poll_response pr
JOIN poll p ON pr.poll_id = p.poll_id
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
FROM poll_response pr
JOIN poll p ON pr.poll_id = p.poll_id
WHERE p.created_at BETWEEN ?1 AND ?2`,
},
{
ID: "donations_by_poll",
Name: "Donations by Poll",
Description: "Which polls drive donations - donation amounts per poll",
SQL: `SELECT
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`,
},
{
ID: "response_donation_correlation",
Name: "Response-to-Donation Correlation",
Description: "Are positive responses linked to donations?",
SQL: `SELECT
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
FROM poll_response pr
JOIN poll p ON pr.poll_id = p.poll_id
WHERE p.created_at BETWEEN ?1 AND ?2
GROUP BY question2_vote_again
ORDER BY total_donations DESC`,
},
},
"availability": {
{
ID: "volunteer_availability_schedule",
Name: "Volunteer Availability by Date Range",
Description: "Who can work when - current volunteer availability schedules",
SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name,
u.email,
av.day_of_week,
av.start_time,
av.end_time,
EXTRACT(EPOCH FROM (av.end_time - av.start_time))/3600 as hours_available,
av.created_at as schedule_updated
FROM availability av
JOIN users u ON av.user_id = u.user_id
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",
SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name,
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`,
},
},
}
}
// 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
}
// Generate summary statistics
func generateSummaryStats(result ReportResult) []SummaryStats {
if len(result.Rows) == 0 {
return nil
}
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
}
}
}
return stats
}
// Export CSV
func exportCSV(w http.ResponseWriter, result ReportResult, reportTitle string) {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s_%s.csv\"",
strings.ReplaceAll(strings.ToLower(reportTitle), " ", "_"),
time.Now().Format("2006-01-02")))
// Write header
fmt.Fprintf(w, "%s\n", strings.Join(result.Columns, ","))
// Write data
for _, row := range result.Rows {
var csvRow []string
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, `"`, `""`) + `"`
}
csvRow = append(csvRow, cellStr)
}
fmt.Fprintf(w, "%s\n", strings.Join(csvRow, ","))
}
}