CSV Import is now working

This commit is contained in:
Mann Patel
2025-09-03 14:35:47 -06:00
parent 7f2b7e481a
commit 86d733e80e
20 changed files with 2160 additions and 1626 deletions

View File

@@ -1,18 +1,4 @@
# Poll-system # Poll-system
- TODO: Update the Database On the linode server - TODO: Reports Generation, Export csv, Print Pdf, Show Charts
- TODO: Map View - TODO: VOlunteer Schedual or avilablity
- TODO: CSV smart address upload
- TODO: Reports
- TODO: Print Reports properly with print button
- TODO: Natural Language Filtering e.i. (From _DB1_,_DB2_,_DB3_, ... Where Date = _date_ And Time = _Time_)
- which volunteer whent to what address
- what are the poll respoce of a address
- what are the poll respoces of a vouneered address
- what are the admutn donat filtered by volunteer, address,
- which team did most appointments
- what is are the poeple in that team
- how much money the tam made
- what are the csv imported data
- what are the poll taken data
- what is the simple the

141
app/database/schema.sql Normal file
View File

@@ -0,0 +1,141 @@
CREATE TABLE role (
role_id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL, -- admin, volunteer, team_lead, manager, terry
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- COMBINED: users + admin_settings in one table
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(150) UNIQUE NOT NULL,
phone VARCHAR(20),
password TEXT NOT NULL,
role_id INT REFERENCES role(role_id) ON DELETE SET NULL,
admin_code CHAR(6) UNIQUE,
-- Admin settings combined here
ward_settings TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE address_database (
address_id SERIAL PRIMARY KEY,
address VARCHAR(255),
street_name VARCHAR(100),
street_type VARCHAR(50),
street_quadrant VARCHAR(50),
house_number VARCHAR(20),
house_alpha VARCHAR(10),
postal_code VARCHAR(10),
longitude DECIMAL(9,6),
latitude DECIMAL(9,6),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE team (
team_id SERIAL PRIMARY KEY,
team_lead_id INT REFERENCES users(user_id) ON DELETE SET NULL,
volunteer_id INT REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE admin_volunteers (
admin_id INT REFERENCES users(user_id) ON DELETE CASCADE,
volunteer_id INT REFERENCES users(user_id) ON DELETE CASCADE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (admin_id, volunteer_id)
);
CREATE TABLE appointment (
sched_id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(user_id) ON DELETE CASCADE,
address_id INT REFERENCES address_database(address_id) ON DELETE CASCADE,
appointment_date DATE,
appointment_time TIME,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE poll(
poll_id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users ON DELETE CASCADE,
address_id INTEGER REFERENCES address_database ON DELETE CASCADE,
donation_amount integer default 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE poll_response
(
poll_response_id SERIAL PRIMARY KEY,
poll_id INTEGER REFERENCES poll(poll_id) ON DELETE CASCADE,
respondent_postal_code VARCHAR(10), -- Postal code of respondent
question1_voted_before BOOLEAN, -- Have you voted before?
question2_vote_again BOOLEAN, -- Will you vote again for this candidate?
question3_lawn_signs INTEGER DEFAULT 0, -- How many lawn signs needed
question4_banner_signs INTEGER DEFAULT 0, -- How many banner signs needed
question5_thoughts TEXT, -- Write your thoughts
signage_status VARCHAR(50) DEFAULT 'requested' CHECK (signage_status IN ('requested', 'delivered', 'cancelled')),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE post (
post_id SERIAL PRIMARY KEY,
author_id INT REFERENCES users(user_id) ON DELETE CASCADE,
content TEXT,
image_url TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE availability (
availability_id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(user_id) ON DELETE CASCADE,
day_of_week VARCHAR(20),
start_time TIME,
end_time TIME,
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes for better performance
CREATE INDEX idx_poll_response_poll_id ON poll_response(poll_id);
CREATE INDEX idx_poll_response_postal_code ON poll_response(respondent_postal_code);
CREATE INDEX idx_poll_response_signage_status ON poll_response(signage_status);
CREATE INDEX idx_poll_user_id ON poll(user_id);
-- Function to generate a 6-character random admin code
CREATE OR REPLACE FUNCTION generate_admin_code()
RETURNS trigger AS $$
BEGIN
IF (SELECT name FROM role WHERE role_id = NEW.role_id) = 'admin' THEN
NEW.admin_code := substring(md5(random()::text) FROM 1 FOR 6);
ELSE
NEW.admin_code := NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to automatically generate admin_code on INSERT
CREATE TRIGGER set_admin_code
BEFORE INSERT ON users
FOR EACH ROW
EXECUTE PROCEDURE generate_admin_code();
INSERT INTO role (role_id, name) VALUES
(1, 'admin'),
(2, 'team_lead'),
(3, 'volunteer')
ON CONFLICT DO NOTHING;

View File

@@ -107,7 +107,7 @@ func AddressHandler(w http.ResponseWriter, r *http.Request) {
FROM address_database a FROM address_database a
LEFT JOIN appointment ap ON a.address_id = ap.address_id LEFT JOIN appointment ap ON a.address_id = ap.address_id
LEFT JOIN users u ON ap.user_id = u.user_id LEFT JOIN users u ON ap.user_id = u.user_id
WHERE a.street_quadrant = 'ne' WHERE a.street_quadrant = 'NE'
ORDER BY a.address_id ORDER BY a.address_id
LIMIT $1 OFFSET $2 LIMIT $1 OFFSET $2
`, pageSize, offset) `, pageSize, offset)
@@ -215,7 +215,7 @@ func AddressHandler(w http.ResponseWriter, r *http.Request) {
"ActiveSection": "address", "ActiveSection": "address",
"Addresses": addresses, "Addresses": addresses,
"Users": users, "Users": users,
"UserName": username, "UserName": username,
"Role": "admin", "Role": "admin",
"Pagination": pagination, "Pagination": pagination,
}) })

View File

@@ -6,23 +6,32 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/patel-mann/poll-system/app/internal/models" "github.com/patel-mann/poll-system/app/internal/models"
"github.com/patel-mann/poll-system/app/internal/utils" "github.com/patel-mann/poll-system/app/internal/utils"
) )
// AddressMatch represents a potential address match with similarity score
type AddressMatch struct {
AddressID int
Address string
CurrentStatus bool
SimilarityScore float64
}
// CSVUploadResult holds the results of CSV processing // CSVUploadResult holds the results of CSV processing
type CSVUploadResult struct { type CSVUploadResult struct {
TotalRecords int TotalRecords int
ValidatedCount int ValidatedCount int
NotFoundCount int NotFoundCount int
ErrorCount int ErrorCount int
ValidatedAddresses []string ValidatedAddresses []string
NotFoundAddresses []string NotFoundAddresses []string
ErrorMessages []string ErrorMessages []string
FuzzyMatches []string // New field for fuzzy matches
} }
// Combined CSV Upload Handler - handles both GET (display form) and POST (process CSV) // Combined CSV Upload Handler - handles both GET (display form) and POST (process CSV)
@@ -69,6 +78,17 @@ func CSVUploadHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Get similarity threshold (optional, default to 0.8)
similarityThresholdStr := r.FormValue("similarity_threshold")
similarityThreshold := 0.8 // Default threshold
if similarityThresholdStr != "" {
if threshold, err := strconv.ParseFloat(similarityThresholdStr, 64); err == nil {
if threshold >= 0.0 && threshold <= 1.0 {
similarityThreshold = threshold
}
}
}
// Get uploaded file // Get uploaded file
file, header, err := r.FormFile("csv_file") file, header, err := r.FormFile("csv_file")
if err != nil { if err != nil {
@@ -116,12 +136,13 @@ func CSVUploadHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Process addresses // Process addresses with fuzzy matching
result := processAddressValidation(allRows[1:], addressColumn) // Skip header result := processAddressValidationWithFuzzyMatching(allRows[1:], addressColumn, similarityThreshold)
// Add result to template data // Add result to template data
templateData["Result"] = result templateData["Result"] = result
templateData["FileName"] = header.Filename templateData["FileName"] = header.Filename
templateData["SimilarityThreshold"] = similarityThreshold
// Render the same template with results // Render the same template with results
utils.Render(w, "csv-upload.html", templateData) utils.Render(w, "csv-upload.html", templateData)
@@ -132,12 +153,20 @@ func CSVUploadHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
} }
// processAddressValidation processes CSV data and validates addresses // processAddressValidationWithFuzzyMatching processes CSV data with fuzzy string matching
func processAddressValidation(rows [][]string, addressColumn int) CSVUploadResult { func processAddressValidationWithFuzzyMatching(rows [][]string, addressColumn int, threshold float64) CSVUploadResult {
result := CSVUploadResult{ result := CSVUploadResult{
TotalRecords: len(rows), TotalRecords: len(rows),
} }
// Pre-load all addresses from database for fuzzy matching
dbAddresses, err := loadAllAddressesFromDB()
if err != nil {
result.ErrorCount = len(rows)
result.ErrorMessages = append(result.ErrorMessages, "Failed to load addresses from database: "+err.Error())
return result
}
for i, row := range rows { for i, row := range rows {
// Check if the row has enough columns // Check if the row has enough columns
if addressColumn >= len(row) { if addressColumn >= len(row) {
@@ -147,116 +176,217 @@ func processAddressValidation(rows [][]string, addressColumn int) CSVUploadResul
continue continue
} }
// Get and normalize address // Get and normalize address from CSV
address := strings.ToLower(strings.TrimSpace(row[addressColumn])) csvAddress := normalizeAddress(row[addressColumn])
if address == "" { if csvAddress == "" {
result.ErrorCount++ result.ErrorCount++
result.ErrorMessages = append(result.ErrorMessages, result.ErrorMessages = append(result.ErrorMessages,
fmt.Sprintf("Row %d: Empty address", i+2)) fmt.Sprintf("Row %d: Empty address", i+2))
continue continue
} }
// Check if address exists in database // Find best matches using fuzzy string matching
var addressID int matches := findBestMatches(csvAddress, dbAddresses, 5) // Get top 5 matches
var currentStatus bool
err := models.DB.QueryRow(`
SELECT address_id, visited_validated
FROM address_database
WHERE LOWER(TRIM(address)) = $1
`, address).Scan(&addressID, &currentStatus)
if err != nil { if len(matches) == 0 {
// Address not found
result.NotFoundCount++ result.NotFoundCount++
result.NotFoundAddresses = append(result.NotFoundAddresses, address) result.NotFoundAddresses = append(result.NotFoundAddresses, csvAddress)
continue continue
} }
// Address found - update validation status if not already validated // Get the best match
if !currentStatus { bestMatch := matches[0]
_, err = models.DB.Exec(`
UPDATE address_database
SET visited_validated = true, updated_at = NOW()
WHERE address_id = $1
`, addressID)
// Check if the best match meets our similarity threshold
if bestMatch.SimilarityScore < threshold {
result.ErrorCount++
result.ErrorMessages = append(result.ErrorMessages,
fmt.Sprintf("Row %d: No good match found for '%s' (best match: '%s' with score %.2f, threshold: %.2f)",
i+2, csvAddress, bestMatch.Address, bestMatch.SimilarityScore, threshold))
continue
}
// Update validation status if not already validated
if !bestMatch.CurrentStatus {
err = updateAddressValidation(bestMatch.AddressID)
if err != nil { if err != nil {
result.ErrorCount++ result.ErrorCount++
result.ErrorMessages = append(result.ErrorMessages, result.ErrorMessages = append(result.ErrorMessages,
fmt.Sprintf("Row %d: Database update error for address '%s'", i+2, address)) fmt.Sprintf("Row %d: Database update error for address '%s'", i+2, csvAddress))
log.Printf("Error updating address %d: %v", addressID, err) log.Printf("Error updating address %d: %v", bestMatch.AddressID, err)
continue continue
} }
result.ValidatedCount++ result.ValidatedCount++
result.ValidatedAddresses = append(result.ValidatedAddresses, address) matchInfo := fmt.Sprintf("%s → %s (score: %.2f)", csvAddress, bestMatch.Address, bestMatch.SimilarityScore)
result.ValidatedAddresses = append(result.ValidatedAddresses, matchInfo)
} else { } else {
// Address was already validated - still count as validated // Address was already validated
result.ValidatedCount++ result.ValidatedCount++
result.ValidatedAddresses = append(result.ValidatedAddresses, address+" (already validated)") matchInfo := fmt.Sprintf("%s → %s (score: %.2f, already validated)", csvAddress, bestMatch.Address, bestMatch.SimilarityScore)
result.ValidatedAddresses = append(result.ValidatedAddresses, matchInfo)
}
// Add fuzzy match info if it's not an exact match
if bestMatch.SimilarityScore < 1.0 {
fuzzyInfo := fmt.Sprintf("CSV: '%s' matched to DB: '%s' (similarity: %.2f)",
csvAddress, bestMatch.Address, bestMatch.SimilarityScore)
result.FuzzyMatches = append(result.FuzzyMatches, fuzzyInfo)
} }
} }
return result return result
} }
// Optional: Keep the export function if you need it // normalizeAddress trims spaces and converts to lowercase
// ExportValidatedAddressesHandler exports validated addresses to CSV func normalizeAddress(address string) string {
func ExportValidatedAddressesHandler(w http.ResponseWriter, r *http.Request) { return strings.ToLower(strings.TrimSpace(address))
// Query validated addresses }
// loadAllAddressesFromDB loads all addresses from the database for fuzzy matching
func loadAllAddressesFromDB() ([]AddressMatch, error) {
rows, err := models.DB.Query(` rows, err := models.DB.Query(`
SELECT SELECT address_id, address, visited_validated
a.address_id, FROM address_database
a.address,
a.street_name,
a.street_type,
a.street_quadrant,
a.house_number,
COALESCE(a.house_alpha, '') as house_alpha,
a.longitude,
a.latitude,
a.visited_validated,
a.created_at,
a.updated_at,
CASE
WHEN ap.sched_id IS NOT NULL THEN true
ELSE false
END as assigned,
COALESCE(u.first_name || ' ' || u.last_name, '') as user_name,
COALESCE(u.email, '') as user_email,
COALESCE(ap.appointment_date::text, '') as appointment_date,
COALESCE(ap.appointment_time::text, '') as appointment_time
FROM address_database a
LEFT JOIN appointment ap ON a.address_id = ap.address_id
LEFT JOIN users u ON ap.user_id = u.user_id
WHERE a.visited_validated = true
ORDER BY a.updated_at DESC
`) `)
if err != nil { if err != nil {
log.Println("Export query error:", err) return nil, err
http.Error(w, "Database error", http.StatusInternalServerError)
return
} }
defer rows.Close() defer rows.Close()
// Set response headers for CSV download var addresses []AddressMatch
filename := fmt.Sprintf("validated_addresses_%s.csv", time.Now().Format("2006-01-02_15-04-05")) for rows.Next() {
w.Header().Set("Content-Type", "text/csv") var match AddressMatch
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) var rawAddress string
// Create CSV writer err := rows.Scan(&match.AddressID, &rawAddress, &match.CurrentStatus)
writer := csv.NewWriter(w) if err != nil {
defer writer.Flush() log.Printf("Error scanning address row: %v", err)
continue
}
// Write header // Normalize the address from database
header := []string{ match.Address = normalizeAddress(rawAddress)
"Address ID", "Address", "Street Name", "Street Type", "Street Quadrant", addresses = append(addresses, match)
"House Number", "House Alpha", "Longitude", "Latitude", "Validated",
"Created At", "Updated At", "Assigned", "Assigned User", "User Email",
"Appointment Date", "Appointment Time",
} }
writer.Write(header)
// Write data rows (you'll need to define AddressWithDetails struct) return addresses, rows.Err()
// Implementation depends on your existing struct definitions }
// findBestMatches finds the top N best matches for a given address
func findBestMatches(csvAddress string, dbAddresses []AddressMatch, topN int) []AddressMatch {
// Calculate similarity scores for all addresses
var matches []AddressMatch
for _, dbAddr := range dbAddresses {
score := calculateSimilarity(csvAddress, dbAddr.Address)
match := AddressMatch{
AddressID: dbAddr.AddressID,
Address: dbAddr.Address,
CurrentStatus: dbAddr.CurrentStatus,
SimilarityScore: score,
}
matches = append(matches, match)
}
// Sort by similarity score (descending)
sort.Slice(matches, func(i, j int) bool {
return matches[i].SimilarityScore > matches[j].SimilarityScore
})
// Return top N matches
if len(matches) > topN {
return matches[:topN]
}
return matches
}
// calculateSimilarity calculates Levenshtein distance-based similarity score
func calculateSimilarity(s1, s2 string) float64 {
if s1 == s2 {
return 1.0
}
distance := levenshteinDistance(s1, s2)
maxLen := max(len(s1), len(s2))
if maxLen == 0 {
return 1.0
}
similarity := 1.0 - float64(distance)/float64(maxLen)
return max(0.0, similarity)
}
// levenshteinDistance calculates the Levenshtein distance between two strings
func levenshteinDistance(s1, s2 string) int {
if len(s1) == 0 {
return len(s2)
}
if len(s2) == 0 {
return len(s1)
}
// Create a matrix
matrix := make([][]int, len(s1)+1)
for i := range matrix {
matrix[i] = make([]int, len(s2)+1)
}
// Initialize first row and column
for i := 0; i <= len(s1); i++ {
matrix[i][0] = i
}
for j := 0; j <= len(s2); j++ {
matrix[0][j] = j
}
// Fill the matrix
for i := 1; i <= len(s1); i++ {
for j := 1; j <= len(s2); j++ {
cost := 0
if s1[i-1] != s2[j-1] {
cost = 1
}
matrix[i][j] = min(
matrix[i-1][j]+1, // deletion
matrix[i][j-1]+1, // insertion
matrix[i-1][j-1]+cost, // substitution
)
}
}
return matrix[len(s1)][len(s2)]
}
// updateAddressValidation updates an address validation status
func updateAddressValidation(addressID int) error {
_, err := models.DB.Exec(`
UPDATE address_database
SET visited_validated = true, updated_at = NOW()
WHERE address_id = $1
`, addressID)
return err
}
// Helper functions for different types
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func maxFloat64(a, b float64) float64 {
if a > b {
return a
}
return b
} }

View File

@@ -33,7 +33,7 @@ func AdminDashboardHandler(w http.ResponseWriter, r *http.Request) {
// 2. Total donations from polls // 2. Total donations from polls
err = models.DB.QueryRow(` err = models.DB.QueryRow(`
SELECT COALESCE(SUM(amount_donated), 0) SELECT COALESCE(SUM(amount_donated), 0)
FROM poll; FROM poll_response;
`).Scan(&totalDonations) `).Scan(&totalDonations)
if err != nil { if err != nil {
log.Println("Donation query error:", err) log.Println("Donation query error:", err)

View File

@@ -0,0 +1,186 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"github.com/patel-mann/poll-system/app/internal/models"
)
// ValidatedAddress represents a validated address with coordinates
type ValidatedAddress struct {
AddressID int `json:"address_id"`
Address string `json:"address"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
HouseNumber string `json:"house_number"`
StreetName string `json:"street_name"`
StreetType string `json:"street_type"`
Quadrant string `json:"street_quadrant"`
UpdatedAt string `json:"updated_at"`
}
// GetValidatedAddressesHandler returns all validated addresses with their coordinates as JSON
func GetValidatedAddressesHandler(w http.ResponseWriter, r *http.Request) {
// Only allow GET requests
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Optional: Check if user is authenticated (depending on your auth system)
// _, authenticated := models.GetCurrentUserName(r)
// if !authenticated {
// http.Error(w, "Unauthorized", http.StatusUnauthorized)
// return
// }
// Query validated addresses from database
addresses, err := fetchValidatedAddresses()
if err != nil {
log.Printf("Error fetching validated addresses: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Set response headers for JSON
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // Adjust based on your CORS policy
// Encode and send JSON response
if err := json.NewEncoder(w).Encode(addresses); err != nil {
log.Printf("Error encoding JSON response: %v", err)
http.Error(w, "Error encoding response", http.StatusInternalServerError)
return
}
log.Printf("Successfully returned %d validated addresses", addresses)
}
// fetchValidatedAddresses retrieves all validated addresses from the database
func fetchValidatedAddresses() ([]ValidatedAddress, error) {
query := `
SELECT
address_id,
address,
longitude,
latitude,
COALESCE(house_number, '') as house_number,
COALESCE(street_name, '') as street_name,
COALESCE(street_type, '') as street_type,
COALESCE(street_quadrant, '') as street_quadrant,
updated_at::text
FROM address_database
WHERE visited_validated = true
AND longitude IS NOT NULL
AND latitude IS NOT NULL
AND longitude != 0
AND latitude != 0
ORDER BY updated_at DESC
`
rows, err := models.DB.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var addresses []ValidatedAddress
for rows.Next() {
var addr ValidatedAddress
err := rows.Scan(
&addr.AddressID,
&addr.Address,
&addr.Longitude,
&addr.Latitude,
&addr.HouseNumber,
&addr.StreetName,
&addr.StreetType,
&addr.Quadrant,
&addr.UpdatedAt,
)
if err != nil {
log.Printf("Error scanning address row: %v", err)
continue
}
addresses = append(addresses, addr)
}
if err = rows.Err(); err != nil {
return nil, err
}
return addresses, nil
}
// GetValidatedAddressesStatsHandler returns statistics about validated addresses
func GetValidatedAddressesStatsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
stats, err := getValidatedAddressesStats()
if err != nil {
log.Printf("Error fetching address stats: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
// AddressStats represents statistics about addresses
type AddressStats struct {
TotalValidated int `json:"total_validated"`
TotalAddresses int `json:"total_addresses"`
ValidatedWithCoords int `json:"validated_with_coords"`
RecentlyValidated int `json:"recently_validated_24h"`
}
// getValidatedAddressesStats gets statistics about validated addresses
func getValidatedAddressesStats() (AddressStats, error) {
var stats AddressStats
// Get total validated count
err := models.DB.QueryRow("SELECT COUNT(*) FROM address_database WHERE visited_validated = true").
Scan(&stats.TotalValidated)
if err != nil {
return stats, err
}
// Get total addresses count
err = models.DB.QueryRow("SELECT COUNT(*) FROM address_database").
Scan(&stats.TotalAddresses)
if err != nil {
return stats, err
}
// Get validated with coordinates count
err = models.DB.QueryRow(`
SELECT COUNT(*) FROM address_database
WHERE visited_validated = true
AND longitude IS NOT NULL
AND latitude IS NOT NULL
AND longitude != 0
AND latitude != 0
`).Scan(&stats.ValidatedWithCoords)
if err != nil {
return stats, err
}
// Get recently validated (last 24 hours)
err = models.DB.QueryRow(`
SELECT COUNT(*) FROM address_database
WHERE visited_validated = true
AND updated_at > NOW() - INTERVAL '24 hours'
`).Scan(&stats.RecentlyValidated)
if err != nil {
return stats, err
}
return stats, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -162,8 +162,8 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
func RegisterHandler(w http.ResponseWriter, r *http.Request) { func RegisterHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
utils.Render(w, "register.html", map[string]interface{}{ utils.Render(w, "layout.html", map[string]interface{}{
"Title": "Register", "Title": "layout",
"IsAuthenticated": false, "IsAuthenticated": false,
}) })
return return

View File

@@ -22,6 +22,13 @@ type VolunteerStatistics struct {
BannerSignsRequested int BannerSignsRequested int
PollCompletionPercent int PollCompletionPercent int
} }
type TeamMate struct {
UserID int
FullName string
Phone string
Role string
IsLead bool
}
// VolunteerPostsHandler - Dashboard view for volunteers with posts and statistics // VolunteerPostsHandler - Dashboard view for volunteers with posts and statistics
func VolunteerPostsHandler(w http.ResponseWriter, r *http.Request) { func VolunteerPostsHandler(w http.ResponseWriter, r *http.Request) {
@@ -71,6 +78,38 @@ func VolunteerPostsHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
// Fetch teammates
teammatesRows, err := models.DB.Query(`
SELECT u.user_id,
u.first_name || ' ' || u.last_name AS full_name,
COALESCE(u.phone, '') AS phone,
r.name AS role
FROM users u
JOIN role r ON u.role_id = r.role_id
JOIN team tm ON u.user_id = tm.volunteer_id OR u.user_id = tm.team_lead_id
WHERE tm.team_id = (
SELECT team_id
FROM team
WHERE volunteer_id = $1 or team_lead_id = $2
)
ORDER BY CASE WHEN r.name = 'team_lead' THEN 0 ELSE 1 END, u.first_name;
`, CurrentUserID, CurrentUserID)
if err != nil {
fmt.Printf("Database query error (teammates): %v\n", err)
}
defer teammatesRows.Close()
var teammates []TeamMate
for teammatesRows.Next() {
var t TeamMate
if err := teammatesRows.Scan(&t.UserID, &t.FullName, &t.Phone, &t.Role); err != nil {
fmt.Printf("Row scan error (teammates): %v\n", err)
continue
}
teammates = append(teammates, t)
}
// Get volunteer statistics // Get volunteer statistics
stats, err := getVolunteerStatistics(CurrentUserID) stats, err := getVolunteerStatistics(CurrentUserID)
@@ -93,6 +132,7 @@ func VolunteerPostsHandler(w http.ResponseWriter, r *http.Request) {
"UserName": username, "UserName": username,
"Posts": posts, "Posts": posts,
"Statistics": stats, "Statistics": stats,
"Teammates": teammates,
"ActiveSection": "dashboard", "ActiveSection": "dashboard",
"IsVolunteer": true, "IsVolunteer": true,
}) })

View File

@@ -88,7 +88,12 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
pollID := r.FormValue("poll_id") pollID := r.FormValue("poll_id")
postalCode := r.FormValue("postal_code") postalCode := r.FormValue("postal_code")
// Parse integer values
question3LawnSigns, _ := strconv.Atoi(r.FormValue("question3_lawn_signs"))
question4BannerSigns, _ := strconv.Atoi(r.FormValue("question4_banner_signs"))
question5Thoughts := r.FormValue("question5_thoughts") question5Thoughts := r.FormValue("question5_thoughts")
question6donation := r.FormValue("question6_amount")
// Parse boolean values // Parse boolean values
var question1VotedBefore *bool var question1VotedBefore *bool
@@ -115,19 +120,16 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
// Parse integer values
question3LawnSigns, _ := strconv.Atoi(r.FormValue("question3_lawn_signs"))
question4BannerSigns, _ := strconv.Atoi(r.FormValue("question4_banner_signs"))
// Insert poll response // Insert poll response
_, err = models.DB.Exec(` _, err = models.DB.Exec(`
INSERT INTO poll_response ( INSERT INTO poll_response (
poll_id, respondent_postal_code, question1_voted_before, poll_id, respondent_postal_code, question1_voted_before,
question2_vote_again, question3_lawn_signs, question4_banner_signs, question2_vote_again, question3_lawn_signs, question4_banner_signs,
question5_thoughts question5_thoughts, question6_donation_amount
) VALUES ($1, $2, $3, $4, $5, $6, $7) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, pollID, postalCode, question1VotedBefore, question2VoteAgain, `, pollID, postalCode, question1VotedBefore, question2VoteAgain,
question3LawnSigns, question4BannerSigns, question5Thoughts) question3LawnSigns, question4BannerSigns, question5Thoughts, question6donation)
if err != nil { if err != nil {
fmt.Print(err) fmt.Print(err)

View File

@@ -10,233 +10,365 @@
rel="stylesheet" rel="stylesheet"
/> />
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script <link
type="text/javascript" rel="stylesheet"
src="https://www.gstatic.com/charts/loader.js" href="https://cdn.jsdelivr.net/npm/ol@7.5.2/ol.css"
></script> />
<script src="https://cdn.jsdelivr.net/npm/ol@7.5.2/dist/ol.js"></script>
<style>
/* CRITICAL: Prevent any duplicate maps */
.ol-viewport {
max-width: 100% !important;
max-height: 700px !important;
}
#single-map {
width: 100%;
height: 700px;
border: 1px solid #e5e7eb;
}
.map-controls {
position: absolute;
top: 10px;
left: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
.control-button {
background: white;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.ol-popup {
position: absolute;
background-color: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 15px;
border-radius: 8px;
border: 1px solid #e5e7eb;
bottom: 12px;
left: -50px;
min-width: 200px;
max-width: 300px;
}
.ol-popup:after {
top: 100%;
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
border-color: rgba(255, 255, 255, 0);
border-top-color: #ffffff;
border-width: 10px;
left: 48px;
margin-left: -10px;
}
</style>
</head> </head>
<body class="bg-gray-50"> <body class="bg-gray-50">
<!-- Full Width Container --> <!-- Navigation -->
<div class="min-h-screen w-full flex flex-col"> <div class="bg-white border-b border-gray-200 w-full">
<!-- Main Dashboard Content --> <div class="px-8 py-6">
<div class="w-full"> <div class="flex items-center justify-between">
<!-- Full Width Container --> <div class="flex items-center gap-3">
<div class="min-h-screen w-full flex flex-col"> <div class="w-8 h-8 bg-blue-600 flex items-center justify-center">
<!-- Top Navigation Bar --> <i class="fas fa-chart-bar text-white text-sm"></i>
<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>
<span class="text-xl font-semibold text-gray-900"
>Dashboard Overview</span
>
</div> </div>
</div> <div class="flex items-center gap-4">
</div> <button
class="px-6 py-2.5 bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors"
onclick="refreshMap()"
>
<i class="fas fa-sync-alt mr-2"></i>Refresh Map
</button>
<!-- Stats Grid - Full Width --> <button
<div class="px-6 py-2.5 bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 bg-white border-b border-gray-200" onclick="window.location.href='/addresses/upload-csv'"
> >
<!-- Active Volunteers --> <i class="fas fa-upload mr-2"></i>Import Data
<div </button>
class="border-r border-gray-200 p-8 hover:bg-gray-50 transition-colors cursor-pointer"
onclick="focusMap()"
>
<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">
Active Volunteers
</p>
<p class="text-2xl font-bold text-gray-900">
{{.VolunteerCount}}
</p>
</div>
</div>
</div>
<!-- Addresses Visited -->
<div
class="border-r border-gray-200 p-8 hover:bg-gray-50 transition-colors cursor-pointer"
onclick="updateChart('visitors')"
>
<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">
Addresses Visited
</p>
<p class="text-2xl font-bold text-gray-900">
{{.ValidatedCount}}
</p>
</div>
</div>
</div>
<!-- Total Donations -->
<div
class="border-r border-gray-200 p-8 hover:bg-gray-50 transition-colors cursor-pointer"
onclick="updateChart('revenue')"
>
<div class="flex items-center">
<div
class="w-12 h-12 bg-blue-50 flex items-center justify-center"
>
<i class="fas fa-dollar-sign text-blue-600 text-lg"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600 mb-1">Donation</p>
<p class="text-2xl font-bold text-gray-900">
${{.TotalDonations}}
</p>
</div>
</div>
</div>
<!-- Houses Left -->
<div
class="p-8 hover:bg-gray-50 transition-colors cursor-pointer"
onclick="updateChart('conversion')"
>
<div class="flex items-center">
<div
class="w-12 h-12 bg-blue-50 flex items-center justify-center"
>
<i class="fas fa-percentage text-blue-600 text-lg"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600 mb-1">
Houses Left
</p>
<p class="text-2xl font-bold text-gray-900">
{{.HousesLeftPercent}}%
</p>
</div>
</div>
</div>
</div>
<!-- Map 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">
Location Analytics
</h3>
<div id="map" class="w-full h-[850px] border border-gray-200"></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Stats Grid -->
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 bg-white border-b border-gray-200"
>
<div class="border-r border-gray-200 p-8">
<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">
Active Volunteers
</p>
<p class="text-2xl font-bold text-gray-900">{{.VolunteerCount}}</p>
</div>
</div>
</div>
<div class="border-r border-gray-200 p-8">
<div class="flex items-center">
<div class="w-12 h-12 bg-green-50 flex items-center justify-center">
<i class="fas fa-map-marker-alt text-green-600 text-lg"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600 mb-1">
Addresses Visited
</p>
<p class="text-2xl font-bold text-gray-900">{{.ValidatedCount}}</p>
<p id="marker-count" class="text-xs text-gray-500">Loading...</p>
</div>
</div>
</div>
<div class="border-r border-gray-200 p-8">
<div class="flex items-center">
<div class="w-12 h-12 bg-yellow-50 flex items-center justify-center">
<i class="fas fa-dollar-sign text-yellow-600 text-lg"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600 mb-1">Donation</p>
<p class="text-2xl font-bold text-gray-900">${{.TotalDonations}}</p>
</div>
</div>
</div>
<div class="p-8">
<div class="flex items-center">
<div class="w-12 h-12 bg-red-50 flex items-center justify-center">
<i class="fas fa-percentage text-red-600 text-lg"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600 mb-1">Houses Left</p>
<p class="text-2xl font-bold text-gray-900">
{{.HousesLeftPercent}}%
</p>
</div>
</div>
</div>
</div>
<!-- SINGLE MAP SECTION -->
<div class="bg-white w-full relative">
<div class="map-controls">
<button class="control-button" onclick="refreshMap()">
<i class="fas fa-sync-alt"></i> Refresh
</button>
<button class="control-button" onclick="fitAllMarkers()">
<i class="fas fa-expand-arrows-alt"></i> Fit All
</button>
<button class="control-button" onclick="clearAllMarkers()">
<i class="fas fa-trash"></i> Clear
</button>
</div>
<!-- THIS IS THE ONLY MAP CONTAINER -->
<div id="single-map"></div>
<div id="popup" class="ol-popup">
<a
href="#"
id="popup-closer"
style="
position: absolute;
top: 8px;
right: 8px;
text-decoration: none;
"
>×</a
>
<div id="popup-content"></div>
</div>
</div>
<script> <script>
let map; // Global variables - only one set
let theMap = null;
let markerLayer = null;
let popup = null;
let initialized = false;
function focusMap() { // Clean initialization
// Center map example function initializeMap() {
map.setCenter({ lat: 43.0896, lng: -79.0849 }); // Niagara Falls if (initialized || !window.ol) {
map.setZoom(12); console.log("Map already initialized or OpenLayers not ready");
return;
}
console.log("Initializing single map...");
try {
// Calgary coordinates
const center = ol.proj.fromLonLat([-114.0719, 51.0447]);
// Create the ONE AND ONLY map
theMap = new ol.Map({
target: "single-map",
layers: [
new ol.layer.Tile({
source: new ol.source.OSM(),
}),
],
view: new ol.View({
center: center,
zoom: 11,
}),
});
// Create popup
popup = new ol.Overlay({
element: document.getElementById("popup"),
positioning: "bottom-center",
stopEvent: false,
offset: [0, -50],
});
theMap.addOverlay(popup);
// Close popup handler
document.getElementById("popup-closer").onclick = function () {
popup.setPosition(undefined);
return false;
};
// Create marker layer
markerLayer = new ol.layer.Vector({
source: new ol.source.Vector(),
style: new ol.style.Style({
text: new ol.style.Text({
text: "📍",
font: "24px sans-serif",
fill: new ol.style.Fill({ color: "#EF4444" }),
offsetY: -12, // Adjust vertical position so pin points to location
}),
}),
});
theMap.addLayer(markerLayer);
// Click handler
theMap.on("click", function (event) {
const feature = theMap.forEachFeatureAtPixel(
event.pixel,
function (feature) {
return feature;
}
);
if (feature && feature.get("address_data")) {
const data = feature.get("address_data");
document.getElementById("popup-content").innerHTML = `
<div class="text-sm">
<h4 class="font-semibold text-gray-900 mb-2">Address Details</h4>
<p><strong>Address:</strong> ${data.address}</p>
<p><strong>House #:</strong> ${data.house_number}</p>
<p><strong>Street:</strong> ${data.street_name} ${data.street_type}</p>
<p><strong>ID:</strong> ${data.address_id}</p>
</div>
`;
popup.setPosition(event.coordinate);
} else {
popup.setPosition(undefined);
}
});
initialized = true;
console.log("Map initialized successfully");
// Load markers
setTimeout(loadMarkers, 500);
} catch (error) {
console.error("Map initialization error:", error);
}
} }
function initMap() { // Load validated addresses
const niagaraFalls = { lat: 43.0896, lng: -79.0849 }; async function loadMarkers() {
try {
const response = await fetch("/api/validated-addresses");
const addresses = await response.json();
map = new google.maps.Map(document.getElementById("map"), { console.log(`Loading ${addresses.length} addresses`);
zoom: 12, document.getElementById(
center: niagaraFalls, "marker-count"
}); ).textContent = `${addresses.length} on map`;
new google.maps.Marker({ // Clear existing markers
position: niagaraFalls, markerLayer.getSource().clear();
map,
title: "Niagara Falls", // Add new markers
}); const features = [];
addresses.forEach((addr) => {
if (addr.longitude && addr.latitude) {
const coords = ol.proj.fromLonLat([
addr.longitude,
addr.latitude,
]);
const feature = new ol.Feature({
geometry: new ol.geom.Point(coords),
address_data: addr,
});
features.push(feature);
}
});
markerLayer.getSource().addFeatures(features);
if (features.length > 0) {
const extent = markerLayer.getSource().getExtent();
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] });
}
} catch (error) {
console.error("Error loading markers:", error);
document.getElementById("marker-count").textContent = "Error loading";
}
} }
// Google Charts // Control functions
google.charts.load("current", { packages: ["corechart", "line"] }); function refreshMap() {
google.charts.setOnLoadCallback(drawAnalyticsChart); loadMarkers();
function drawAnalyticsChart() {
var data = new google.visualization.DataTable();
data.addColumn("string", "Time");
data.addColumn("number", "Visitors");
data.addColumn("number", "Revenue");
data.addRows([
["Jan", 4200, 32000],
["Feb", 4800, 38000],
["Mar", 5200, 42000],
["Apr", 4900, 39000],
["May", 5800, 45000],
["Jun", 6200, 48000],
]);
var options = {
title: "Performance Over Time",
backgroundColor: "transparent",
hAxis: { title: "Month" },
vAxis: { title: "Value" },
colors: ["#3B82F6", "#10B981"],
chartArea: {
left: 60,
top: 40,
width: "90%",
height: "70%",
},
legend: { position: "top", alignment: "center" },
};
var chart = new google.visualization.LineChart(
document.getElementById("analytics_chart")
);
chart.draw(data, options);
} }
function updateChart(type) { function fitAllMarkers() {
drawAnalyticsChart(); if (markerLayer && markerLayer.getSource().getFeatures().length > 0) {
const extent = markerLayer.getSource().getExtent();
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] });
}
} }
function clearAllMarkers() {
if (markerLayer) {
markerLayer.getSource().clear();
}
if (popup) {
popup.setPosition(undefined);
}
}
// Initialize when ready
document.addEventListener("DOMContentLoaded", function () {
setTimeout(initializeMap, 1000);
});
</script> </script>
<script
async
defer
src="https://maps.googleapis.com/maps/api/js?key=YOUR_KEY_HERE&callback=initMap"
></script>
</body> </body>
</html> </html>
{{ end }} {{ end }}

View File

@@ -8,11 +8,20 @@
class="w-full lg:w-1/2 flex flex-col gap-4 sm:gap-6 sticky top-0 self-start h-fit" class="w-full lg:w-1/2 flex flex-col gap-4 sm:gap-6 sticky top-0 self-start h-fit"
> >
<!-- Today's Overview --> <!-- Today's Overview -->
<div class="bg-white border-b border-gray-200"> <div class="bg-white border-b border-gray-200" x-data="{ open: true }">
<div class="px-4 sm:px-6 py-4"> <div
<h3 class="text-sm font-semibold text-gray-900 mb-4"> class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer"
@click="open = !open"
>
<h3 class="text-sm font-semibold text-gray-900">
Today's Overview Today's Overview
</h3> </h3>
<i
class="fas"
:class="open ? 'fa-chevron-up' : 'fa-chevron-down'"
></i>
</div>
<div class="px-4 sm:px-6 pb-4" x-show="open" x-collapse>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -23,9 +32,9 @@
</div> </div>
<span class="text-sm text-gray-700">Appointments Today</span> <span class="text-sm text-gray-700">Appointments Today</span>
</div> </div>
<span class="text-lg font-semibold text-gray-900"> <span class="text-lg font-semibold text-gray-900"
{{ .Statistics.AppointmentsToday }} >{{ .Statistics.AppointmentsToday }}</span
</span> >
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -39,9 +48,9 @@
>Appointments Tomorrow</span >Appointments Tomorrow</span
> >
</div> </div>
<span class="text-lg font-semibold text-gray-900"> <span class="text-lg font-semibold text-gray-900"
{{ .Statistics.AppointmentsTomorrow }} >{{ .Statistics.AppointmentsTomorrow }}</span
</span> >
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -53,20 +62,29 @@
</div> </div>
<span class="text-sm text-gray-700">This Week</span> <span class="text-sm text-gray-700">This Week</span>
</div> </div>
<span class="text-lg font-semibold text-gray-900"> <span class="text-lg font-semibold text-gray-900"
{{ .Statistics.AppointmentsThisWeek }} >{{ .Statistics.AppointmentsThisWeek }}</span
</span> >
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Polling Progress --> <!-- Polling Progress -->
<div class="bg-white border-b border-gray-200"> <div class="bg-white border-b border-gray-200" x-data="{ open: true }">
<div class="px-4 sm:px-6 py-4"> <div
<h3 class="text-sm font-semibold text-gray-900 mb-4"> class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer"
@click="open = !open"
>
<h3 class="text-sm font-semibold text-gray-900">
Polling Progress Polling Progress
</h3> </h3>
<i
class="fas"
:class="open ? 'fa-chevron-up' : 'fa-chevron-down'"
></i>
</div>
<div class="px-4 sm:px-6 pb-4" x-show="open" x-collapse>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -77,9 +95,9 @@
</div> </div>
<span class="text-sm text-gray-700">Polls Completed</span> <span class="text-sm text-gray-700">Polls Completed</span>
</div> </div>
<span class="text-lg font-semibold text-green-600"> <span class="text-lg font-semibold text-green-600"
{{ .Statistics.PollsCompleted }} >{{ .Statistics.PollsCompleted }}</span
</span> >
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -91,9 +109,9 @@
</div> </div>
<span class="text-sm text-gray-700">Polls Remaining</span> <span class="text-sm text-gray-700">Polls Remaining</span>
</div> </div>
<span class="text-lg font-semibold text-orange-600"> <span class="text-lg font-semibold text-orange-600"
{{ .Statistics.PollsRemaining }} >{{ .Statistics.PollsRemaining }}</span
</span> >
</div> </div>
<!-- Progress Bar --> <!-- Progress Bar -->
@@ -117,6 +135,44 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Team Members -->
<div class="bg-white border-b border-gray-200" x-data="{ open: true }">
<div
class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer"
@click="open = !open"
>
<h3 class="text-sm font-semibold text-gray-900">Team Members</h3>
<i
class="fas"
:class="open ? 'fa-chevron-up' : 'fa-chevron-down'"
></i>
</div>
<div class="px-4 sm:px-6 pb-4" x-show="open" x-collapse>
<div class="space-y-3">
{{ range .Teammates }}
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">
{{ .FullName }} {{ if .IsLead }}
<span class="ml-2 text-xs text-blue-600 font-semibold"
>{{ .Role }}</span
>
{{ else }}
<span class="ml-2 text-xs text-gray-500">{{ .Role }}</span>
{{ end }}
</p>
</div>
<div class="text-sm text-gray-700">
<i class="fas fa-phone mr-1 text-gray-500"></i>{{ .Phone }}
</div>
</div>
{{ else }}
<p class="text-gray-500 text-sm">No teammates found</p>
{{ end }}
</div>
</div>
</div>
</div> </div>
<!-- Right Column - Statistics --> <!-- Right Column - Statistics -->
<div class="flex-1 lg:flex-none lg:w-1/2 overflow-y-auto pr-2"> <div class="flex-1 lg:flex-none lg:w-1/2 overflow-y-auto pr-2">

View File

@@ -9,6 +9,8 @@
<title>{{if .Title}}{{.Title}}{{else}}Poll System{{end}}</title> <title>{{if .Title}}{{.Title}}{{else}}Poll System{{end}}</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="//unpkg.com/alpinejs" defer></script> <script src="//unpkg.com/alpinejs" defer></script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
@@ -52,7 +54,7 @@
<a href="/posts" class="text-sm font-medium {{if eq .ActiveSection "post"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1"> <a href="/posts" class="text-sm font-medium {{if eq .ActiveSection "post"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
Posts Posts
</a> </a>
<a href="/smart-reports" class="text-sm font-medium {{if eq .ActiveSection "report"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1"> <a href="/reports" class="text-sm font-medium {{if eq .ActiveSection "report"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
Reports Reports
</a> </a>
{{ end }} {{ end }}
@@ -64,9 +66,9 @@
<a href="/volunteer/Addresses" class="text-sm font-medium {{if eq .ActiveSection "address"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1"> <a href="/volunteer/Addresses" class="text-sm font-medium {{if eq .ActiveSection "address"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
Assigned Address Assigned Address
</a> </a>
<a href="/volunteer/schedual" class="text-sm font-medium {{if eq .ActiveSection "schedual"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1"> <!-- <a href="/volunteer/schedual" class="text-sm font-medium {{if eq .ActiveSection "schedual"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
My Schedule My Schedule
</a> </a> -->
{{ end }} {{ end }}
<a href="/profile" class="text-sm font-medium {{if eq .ActiveSection "profile"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1"> <a href="/profile" class="text-sm font-medium {{if eq .ActiveSection "profile"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
@@ -474,7 +476,6 @@
onchange="toggleAdminCodeField()"> onchange="toggleAdminCodeField()">
<option value="">Select role</option> <option value="">Select role</option>
<option value="1">Admin</option> <option value="1">Admin</option>
<option value="2">Team Leader</option>
<option value="3">Volunteer</option> <option value="3">Volunteer</option>
</select> </select>
</div> </div>
@@ -585,7 +586,7 @@
function toggleAdminCodeField() { function toggleAdminCodeField() {
const role = document.getElementById("role").value; const role = document.getElementById("role").value;
const field = document.getElementById("adminCodeField"); const field = document.getElementById("adminCodeField");
field.classList.toggle("hidden", role !== "3"); // show only if Volunteer field.classList.toggle("hidden", role !== "3" && role !== "2"); // show only if Volunteer or Team Leader
} }
// Handle escape key // Handle escape key

View File

@@ -0,0 +1,350 @@
{{ define "content" }}
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Toolbar with Report Selection -->
<div class="bg-gray-50 border-b border-gray-200 px-6 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-sm">
<form method="GET" action="/reports" class="flex items-center gap-3">
<!-- Category Dropdown -->
<div class="relative">
<label for="category" class="text-gray-700 font-medium mr-2">Category:</label>
<select
name="category"
id="category"
onchange="updateReports()"
class="px-3 py-2 text-sm border border-gray-200 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500 min-w-48"
>
<option value="">Select Category</option>
<option value="users" {{if eq .Category "users"}}selected{{end}}>Users & Teams</option>
<option value="addresses" {{if eq .Category "addresses"}}selected{{end}}>Addresses</option>
<option value="appointments" {{if eq .Category "appointments"}}selected{{end}}>Appointments</option>
<option value="polls" {{if eq .Category "polls"}}selected{{end}}>Polls</option>
<option value="responses" {{if eq .Category "responses"}}selected{{end}}>Poll Responses</option>
<option value="availability" {{if eq .Category "availability"}}selected{{end}}>Availability</option>
</select>
</div>
<!-- Report Dropdown -->
<div class="relative">
<label for="report" class="text-gray-700 font-medium mr-2">Report:</label>
<select
name="report"
id="report"
class="px-3 py-2 text-sm border border-gray-200 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500 min-w-64"
>
<option value="">Select Report</option>
{{if .Category}}
{{range .AvailableReports}}
<option value="{{.ID}}" {{if eq .ID $.ReportID}}selected{{end}}>{{.Name}}</option>
{{end}}
{{end}}
</select>
</div>
<!-- Date Range (optional) -->
<div class="flex items-center gap-2">
<label for="date_from" class="text-gray-700 font-medium">From:</label>
<input
type="date"
name="date_from"
id="date_from"
value="{{.DateFrom}}"
class="px-3 py-2 text-sm border border-gray-200 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500"
/>
<label for="date_to" class="text-gray-700 font-medium">To:</label>
<input
type="date"
name="date_to"
id="date_to"
value="{{.DateTo}}"
class="px-3 py-2 text-sm border border-gray-200 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<button
type="submit"
class="px-4 py-2 bg-purple-600 text-white font-medium hover:bg-purple-700 transition-all duration-200 text-sm"
>
<i class="fas fa-chart-bar mr-2"></i>Generate Report
</button>
</form>
</div>
<!-- Actions -->
{{if .Result}}
<div class="flex items-center gap-3 text-sm">
<div class="text-gray-600">
<span>{{.Result.Count}} results</span>
</div>
<button
onclick="exportResults()"
class="px-3 py-1.5 bg-green-600 text-white hover:bg-green-700 transition-colors"
>
<i class="fas fa-download mr-1"></i>Export CSV
</button>
<button
onclick="printReport()"
class="px-3 py-1.5 bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
<i class="fas fa-print mr-1"></i>Print
</button>
</div>
{{end}}
</div>
</div>
<!-- Main Content Area -->
<div class="flex-1 overflow-auto">
{{if .Result}}
{{if .Result.Error}}
<!-- Error State -->
<div class="p-6">
<div class="bg-red-50 border border-red-200 p-6">
<div class="flex items-start">
<div class="w-10 h-10 bg-red-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-exclamation-triangle text-red-600"></i>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-red-800 mb-2">Report Error</h3>
<p class="text-red-700">{{.Result.Error}}</p>
</div>
</div>
</div>
</div>
{{else}}
<!-- Report Header -->
<div class="bg-white border-b border-gray-200 px-6 py-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-gray-900">{{.ReportTitle}}</h2>
<p class="text-sm text-gray-600 mt-1">{{.ReportDescription}}</p>
</div>
<div class="text-sm text-gray-500">
Generated: {{.GeneratedAt}}
</div>
</div>
</div>
<!-- Results Table -->
{{if gt .Result.Count 0}}
<div class="flex-1 overflow-x-auto overflow-y-auto bg-white">
<table class="w-full divide-gray-200 text-sm table-auto">
<thead class="bg-gray-50 divide-gray-200 sticky top-0">
<tr class="text-left text-gray-700 font-medium border-b border-gray-200">
{{range .Result.Columns}}
<th class="px-6 py-3 whitespace-nowrap">{{formatColumnName .}}</th>
{{end}}
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{range .Result.Rows}}
<tr class="hover:bg-gray-50">
{{range .}}
<td class="px-6 py-3 text-sm text-gray-900">{{.}}</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
<!-- Summary Stats (if available) -->
{{if .SummaryStats}}
<div class="bg-gray-50 border-t border-gray-200 px-6 py-4">
<h4 class="text-sm font-semibold text-gray-700 mb-3">Summary Statistics</h4>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{{range .SummaryStats}}
<div class="bg-white border border-gray-200 px-3 py-2">
<div class="text-xs text-gray-500">{{.Label}}</div>
<div class="text-lg font-semibold text-gray-900">{{.Value}}</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{else}}
<!-- No Results State -->
<div class="flex-1 flex items-center justify-center">
<div class="text-center py-12">
<div class="w-16 h-16 bg-gray-100 flex items-center justify-center mx-auto mb-4">
<i class="fas fa-chart-bar text-gray-400 text-xl"></i>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">No Data Found</h3>
<p class="text-gray-500">No results match your selected criteria</p>
</div>
</div>
{{end}}
{{end}}
{{else}}
<!-- Welcome State -->
<div class="flex-1 flex items-center justify-center">
<div class="text-center py-12 max-w-4xl mx-auto px-6">
<div class="mb-8">
<div class="w-20 h-20 bg-gradient-to-br from-purple-600 to-purple-700 flex items-center justify-center mx-auto mb-4">
<i class="fas fa-chart-line text-white text-2xl"></i>
</div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Campaign Reports</h1>
<p class="text-gray-600 text-lg">Generate detailed reports across all your campaign data</p>
</div>
<!-- Report Categories Overview -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-left">
<div class="p-4 bg-white border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all duration-200">
<div class="font-medium text-gray-900 text-sm mb-1">Users & Teams</div>
<div class="text-xs text-gray-500">Volunteer performance, team stats, role distribution</div>
</div>
<div class="p-4 bg-white border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all duration-200">
<div class="font-medium text-gray-900 text-sm mb-1">Address Reports</div>
<div class="text-xs text-gray-500">Coverage areas, visit status, geographical insights</div>
</div>
<div class="p-4 bg-white border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all duration-200">
<div class="font-medium text-gray-900 text-sm mb-1">Appointments</div>
<div class="text-xs text-gray-500">Schedule analysis, completion rates, time trends</div>
</div>
<div class="p-4 bg-white border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all duration-200">
<div class="font-medium text-gray-900 text-sm mb-1">Poll Analytics</div>
<div class="text-xs text-gray-500">Response rates, donation tracking, engagement metrics</div>
</div>
</div>
<div class="mt-6 text-sm text-gray-500">
Select a category above to see available reports
</div>
</div>
</div>
{{end}}
</div>
</div>
<style>
/* Square corners across UI */
* {
border-radius: 0 !important;
}
input, select, button {
transition: all 0.2s ease;
font-weight: 500;
letter-spacing: 0.025em;
}
@media print {
.no-print {
display: none !important;
}
/* Print-specific styles */
body {
background: white !important;
}
.bg-gray-50 {
background: white !important;
}
}
</style>
<script>
// Report definitions for each category
const reportDefinitions = {
users: [
{ id: 'users_by_role', name: 'Users by Role' },
{ id: 'volunteer_activity', name: 'Volunteer Activity Summary' },
{ id: 'team_performance', name: 'Team Performance Report' },
{ id: 'admin_workload', name: 'Admin Workload Analysis' },
{ id: 'inactive_users', name: 'Inactive Users Report' }
],
addresses: [
{ id: 'coverage_by_area', name: 'Coverage by Area' },
{ id: 'visits_by_postal', name: 'Visits by Postal Code' },
{ id: 'unvisited_addresses', name: 'Unvisited Addresses' },
{ id: 'donations_by_location', name: 'Donations by Location' },
{ id: 'address_validation_status', name: 'Address Validation Status' }
],
appointments: [
{ id: 'appointments_by_day', name: 'Appointments by Day' },
{ id: 'completion_rates', name: 'Completion Rates' },
{ id: 'volunteer_schedules', name: 'Volunteer Schedules' },
{ id: 'missed_appointments', name: 'Missed Appointments' },
{ id: 'peak_hours', name: 'Peak Activity Hours' }
],
polls: [
{ id: 'poll_creation_stats', name: 'Poll Creation Statistics' },
{ id: 'donation_analysis', name: 'Donation Analysis' },
{ id: 'active_vs_inactive', name: 'Active vs Inactive Polls' },
{ id: 'poll_trends', name: 'Poll Activity Trends' },
{ id: 'creator_performance', name: 'Creator Performance' }
],
responses: [
{ id: 'voter_status', name: 'Voter Status Report' },
{ id: 'sign_requests', name: 'Sign Requests Summary' },
{ id: 'feedback_analysis', name: 'Feedback Analysis' },
{ id: 'response_trends', name: 'Response Trends' },
{ id: 'repeat_voters', name: 'Repeat Voters Analysis' }
],
availability: [
{ id: 'volunteer_availability', name: 'Volunteer Availability' },
{ id: 'peak_availability', name: 'Peak Availability Times' },
{ id: 'coverage_gaps', name: 'Coverage Gaps' },
{ id: 'schedule_conflicts', name: 'Schedule Conflicts' }
]
};
// Update reports dropdown when category changes
function updateReports() {
const categorySelect = document.getElementById('category');
const reportSelect = document.getElementById('report');
const category = categorySelect.value;
// Clear existing options
reportSelect.innerHTML = '<option value="">Select Report</option>';
if (category && reportDefinitions[category]) {
reportDefinitions[category].forEach(report => {
const option = document.createElement('option');
option.value = report.id;
option.textContent = report.name;
reportSelect.appendChild(option);
});
}
}
// Export results
function exportResults() {
const form = document.querySelector('form');
const formData = new FormData(form);
const params = new URLSearchParams(formData);
params.set('export', 'csv');
window.location.href = `/reports/export?${params.toString()}`;
}
// Print report
function printReport() {
window.print();
}
// Initialize on page load
document.addEventListener("DOMContentLoaded", function () {
updateReports();
// Set default date range (last 30 days)
const dateFrom = document.getElementById('date_from');
const dateTo = document.getElementById('date_to');
if (!dateFrom.value) {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
dateFrom.value = thirtyDaysAgo.toISOString().split('T')[0];
}
if (!dateTo.value) {
dateTo.value = new Date().toISOString().split('T')[0];
}
});
</script>
{{ end }}

View File

@@ -1,752 +0,0 @@
{{ 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>
</head>
<body class="bg-gray-50">
<div class="min-h-screen w-full flex flex-col">
<!-- Header -->
<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-purple-600 flex items-center justify-center rounded"
>
<i class="fas fa-brain text-white text-sm"></i>
</div>
<span class="text-xl font-semibold text-gray-900"
>Smart Reports & Analytics</span
>
</div>
<div class="flex items-center gap-4">
{{if .SmartQuery}}
<button
onclick="exportResults()"
class="px-6 py-2.5 bg-green-600 text-white text-sm font-medium rounded hover:bg-green-700 transition-colors"
>
<i class="fas fa-download mr-2"></i>Export Results
</button>
{{end}}
<button
onclick="clearQuery()"
class="px-6 py-2.5 border border-gray-300 text-gray-700 text-sm font-medium rounded hover:bg-gray-50 transition-colors"
>
<i class="fas fa-eraser mr-2"></i>Clear
</button>
</div>
</div>
</div>
</div>
<!-- Smart Search Interface -->
<div class="bg-white w-full border-b border-gray-200">
<div class="px-8 py-8">
<div class="max-w-4xl mx-auto">
<h2 class="text-2xl font-bold text-gray-900 mb-2">
Ask About Your Data
</h2>
<p class="text-gray-600 mb-8">
Use natural language to query across users, polls, appointments,
addresses, and teams
</p>
<form method="GET" action="/smart-reports" class="mb-8">
<div class="relative">
<div
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
>
<i class="fas fa-magic text-purple-400"></i>
</div>
<input
type="text"
name="smart_query"
value="{{.SmartQuery}}"
placeholder="e.g., 'volunteers who went to Main Street' or 'donations by team 5'"
class="w-full pl-10 pr-4 py-4 text-lg border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
autocomplete="off"
/>
</div>
<div class="flex justify-between items-center mt-4">
<button
type="submit"
class="px-8 py-3 bg-purple-600 text-white font-semibold rounded-lg hover:bg-purple-700 transition-colors"
>
<i class="fas fa-search mr-2"></i>Search
</button>
<button
type="button"
onclick="toggleExamples()"
class="text-purple-600 hover:text-purple-800 text-sm font-medium"
>
<i class="fas fa-lightbulb mr-1"></i>Show Examples
</button>
</div>
</form>
<!-- Query Examples -->
<div id="queryExamples" class="hidden">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
Example Queries
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{{range .QueryExamples}}
<div
class="p-4 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors cursor-pointer"
onclick="useExample('{{.}}')"
>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700">{{.}}</span>
<i class="fas fa-arrow-right text-purple-500 text-xs"></i>
</div>
</div>
{{end}}
</div>
</div>
</div>
</div>
</div>
<!-- Results Section -->
{{if .Result}} {{if .Result.Error}}
<div class="bg-white w-full">
<div class="px-8 py-6">
<div class="bg-red-50 border border-red-200 rounded-lg p-6">
<div class="flex items-center">
<div
class="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center"
>
<i class="fas fa-exclamation-triangle text-red-600 text-sm"></i>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-red-800">Query Error</h3>
<p class="text-red-700 mt-2">{{.Result.Error}}</p>
{{if .Result.Query}}
<details class="mt-4">
<summary class="text-sm text-red-600 cursor-pointer">
Show Generated SQL
</summary>
<pre
class="mt-2 p-3 bg-red-100 text-red-800 text-xs rounded overflow-x-auto"
>
{{.Result.Query}}</pre
>
</details>
{{end}}
</div>
</div>
</div>
</div>
</div>
{{else}}
<!-- Successful Results -->
<div class="bg-white w-full">
<div class="px-8 py-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
Query Results ({{.Result.Count}} records found)
</h3>
<div class="flex items-center gap-4">
<button
onclick="toggleQueryDetails()"
class="text-sm text-gray-500 hover:text-gray-700"
>
<i class="fas fa-code mr-1"></i>Show SQL
</button>
<button
onclick="exportResults()"
class="px-4 py-2 bg-green-600 text-white text-sm rounded hover:bg-green-700 transition-colors"
>
<i class="fas fa-download mr-1"></i>Export CSV
</button>
</div>
</div>
<!-- Query Details (Hidden by default) -->
<div
id="queryDetails"
class="hidden mb-6 p-4 bg-gray-50 rounded-lg border"
>
<h4 class="text-sm font-semibold text-gray-700 mb-2">
Generated SQL Query:
</h4>
<pre class="text-xs text-gray-600 overflow-x-auto">
{{.Result.Query}}</pre
>
</div>
<!-- Results Table -->
{{if gt .Result.Count 0}}
<div class="overflow-x-auto rounded-lg border border-gray-200">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
{{range .Result.Columns}}
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b border-gray-200"
>
{{formatColumnName .}}
</th>
{{end}}
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{{range .Result.Rows}}
<tr class="hover:bg-gray-50 transition-colors">
{{range .}}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{.}}
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-12">
<div
class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4"
>
<i class="fas fa-search text-gray-400 text-xl"></i>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">
No Results Found
</h3>
<p class="text-gray-500">
Try adjusting your query or check the examples below
</p>
</div>
{{end}}
</div>
</div>
{{end}} {{end}}
<!-- Query Builder Assistant -->
<div class="bg-white w-full border-t border-gray-200">
<div class="px-8 py-6">
<div class="max-w-4xl mx-auto">
<h3 class="text-lg font-semibold text-gray-900 mb-6">
Smart Query Builder
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Common Queries -->
<div class="space-y-4">
<h4 class="font-medium text-gray-900">
User & Volunteer Queries
</h4>
<div class="space-y-2">
<button
onclick="useSmartQuery('volunteers who went to')"
class="w-full text-left p-3 bg-blue-50 text-blue-800 rounded border hover:bg-blue-100 transition-colors"
>
<i class="fas fa-user-friends mr-2"></i>Volunteers who went
to...
</button>
<button
onclick="useSmartQuery('users with role admin')"
class="w-full text-left p-3 bg-blue-50 text-blue-800 rounded border hover:bg-blue-100 transition-colors"
>
<i class="fas fa-user-shield mr-2"></i>Users by role
</button>
<button
onclick="useSmartQuery('volunteer activity by month')"
class="w-full text-left p-3 bg-blue-50 text-blue-800 rounded border hover:bg-blue-100 transition-colors"
>
<i class="fas fa-chart-line mr-2"></i>Volunteer activity
</button>
</div>
</div>
<!-- Poll & Donation Queries -->
<div class="space-y-4">
<h4 class="font-medium text-gray-900">
Poll & Donation Queries
</h4>
<div class="space-y-2">
<button
onclick="useSmartQuery('poll responses for')"
class="w-full text-left p-3 bg-green-50 text-green-800 rounded border hover:bg-green-100 transition-colors"
>
<i class="fas fa-poll mr-2"></i>Poll responses for address
</button>
<button
onclick="useSmartQuery('donations by volunteer')"
class="w-full text-left p-3 bg-green-50 text-green-800 rounded border hover:bg-green-100 transition-colors"
>
<i class="fas fa-donate mr-2"></i>Donations by volunteer
</button>
<button
onclick="useSmartQuery('active polls created after 2024-01-01')"
class="w-full text-left p-3 bg-green-50 text-green-800 rounded border hover:bg-green-100 transition-colors"
>
<i class="fas fa-calendar-check mr-2"></i>Active polls by
date
</button>
</div>
</div>
<!-- Team & Address Queries -->
<div class="space-y-4">
<h4 class="font-medium text-gray-900">
Team & Address Queries
</h4>
<div class="space-y-2">
<button
onclick="useSmartQuery('team with most appointments')"
class="w-full text-left p-3 bg-purple-50 text-purple-800 rounded border hover:bg-purple-100 transition-colors"
>
<i class="fas fa-users mr-2"></i>Top performing teams
</button>
<button
onclick="useSmartQuery('visited addresses')"
class="w-full text-left p-3 bg-purple-50 text-purple-800 rounded border hover:bg-purple-100 transition-colors"
>
<i class="fas fa-map-marked-alt mr-2"></i>Visited addresses
</button>
<button
onclick="useSmartQuery('money made by team')"
class="w-full text-left p-3 bg-purple-50 text-purple-800 rounded border hover:bg-purple-100 transition-colors"
>
<i class="fas fa-dollar-sign mr-2"></i>Team earnings
</button>
</div>
</div>
</div>
<!-- Query Syntax Help -->
<div class="mt-8 p-6 bg-gray-50 rounded-lg border border-gray-200">
<h4 class="font-semibold text-gray-900 mb-4">
<i class="fas fa-info-circle text-blue-500 mr-2"></i>Smart Query
Syntax Guide
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm">
<div>
<h5 class="font-medium text-gray-800 mb-2">
Entity Keywords:
</h5>
<ul class="space-y-1 text-gray-600">
<li>
<code class="bg-gray-200 px-1 rounded"
>volunteer, user, admin</code
>
- User data
</li>
<li>
<code class="bg-gray-200 px-1 rounded">poll, polls</code>
- Poll data
</li>
<li>
<code class="bg-gray-200 px-1 rounded"
>address, addresses</code
>
- Address data
</li>
<li>
<code class="bg-gray-200 px-1 rounded"
>appointment, appointments</code
>
- Appointment data
</li>
<li>
<code class="bg-gray-200 px-1 rounded">team, teams</code>
- Team data
</li>
</ul>
</div>
<div>
<h5 class="font-medium text-gray-800 mb-2">
Action Keywords:
</h5>
<ul class="space-y-1 text-gray-600">
<li>
<code class="bg-gray-200 px-1 rounded"
>went to, visited</code
>
- Filter by visits
</li>
<li>
<code class="bg-gray-200 px-1 rounded"
>donated, money, amount</code
>
- Financial data
</li>
<li>
<code class="bg-gray-200 px-1 rounded"
>active, inactive</code
>
- Status filters
</li>
<li>
<code class="bg-gray-200 px-1 rounded"
>most, highest, top</code
>
- Ranking queries
</li>
<li>
<code class="bg-gray-200 px-1 rounded"
>from DATE, after DATE</code
>
- Date filters
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Live Query Suggestions -->
<div class="bg-white w-full border-t border-gray-200">
<div class="px-8 py-6">
<div class="max-w-4xl mx-auto">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
Popular Analysis Questions
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
class="p-4 border border-gray-200 rounded-lg hover:border-purple-300 transition-colors cursor-pointer"
onclick="useSmartQuery('which volunteer went to most addresses')"
>
<h5 class="font-medium text-gray-900 mb-2">
Top Performing Volunteers
</h5>
<p class="text-sm text-gray-600">
Find volunteers with most address visits
</p>
</div>
<div
class="p-4 border border-gray-200 rounded-lg hover:border-purple-300 transition-colors cursor-pointer"
onclick="useSmartQuery('poll responses of visited addresses')"
>
<h5 class="font-medium text-gray-900 mb-2">
Visited Address Polls
</h5>
<p class="text-sm text-gray-600">
Polls from addresses that were visited
</p>
</div>
<div
class="p-4 border border-gray-200 rounded-lg hover:border-purple-300 transition-colors cursor-pointer"
onclick="useSmartQuery('donations filtered by volunteer Sarah')"
>
<h5 class="font-medium text-gray-900 mb-2">
Volunteer Donations
</h5>
<p class="text-sm text-gray-600">
Total donations by specific volunteer
</p>
</div>
<div
class="p-4 border border-gray-200 rounded-lg hover:border-purple-300 transition-colors cursor-pointer"
onclick="useSmartQuery('team that did most appointments')"
>
<h5 class="font-medium text-gray-900 mb-2">Most Active Team</h5>
<p class="text-sm text-gray-600">
Team with highest appointment count
</p>
</div>
<div
class="p-4 border border-gray-200 rounded-lg hover:border-purple-300 transition-colors cursor-pointer"
onclick="useSmartQuery('people in team 1')"
>
<h5 class="font-medium text-gray-900 mb-2">Team Members</h5>
<p class="text-sm text-gray-600">
View all members of a specific team
</p>
</div>
<div
class="p-4 border border-gray-200 rounded-lg hover:border-purple-300 transition-colors cursor-pointer"
onclick="useSmartQuery('unvisited addresses with polls')"
>
<h5 class="font-medium text-gray-900 mb-2">
Missed Opportunities
</h5>
<p class="text-sm text-gray-600">
Addresses with polls but no visits
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Queries History -->
<div class="bg-gray-50 w-full border-t border-gray-200">
<div class="px-8 py-6">
<div class="max-w-4xl mx-auto">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
Quick Actions
</h3>
<div class="flex flex-wrap gap-3">
<button
onclick="useSmartQuery('all users created today')"
class="px-4 py-2 bg-white border border-gray-300 text-gray-700 text-sm rounded hover:bg-gray-50 transition-colors"
>
Today's Users
</button>
<button
onclick="useSmartQuery('donations over 50')"
class="px-4 py-2 bg-white border border-gray-300 text-gray-700 text-sm rounded hover:bg-gray-50 transition-colors"
>
High Donations
</button>
<button
onclick="useSmartQuery('appointments this week')"
class="px-4 py-2 bg-white border border-gray-300 text-gray-700 text-sm rounded hover:bg-gray-50 transition-colors"
>
This Week's Appointments
</button>
<button
onclick="useSmartQuery('inactive polls with donations')"
class="px-4 py-2 bg-white border border-gray-300 text-gray-700 text-sm rounded hover:bg-gray-50 transition-colors"
>
Inactive Paid Polls
</button>
<button
onclick="useSmartQuery('teams created this month')"
class="px-4 py-2 bg-white border border-gray-300 text-gray-700 text-sm rounded hover:bg-gray-50 transition-colors"
>
New Teams
</button>
</div>
</div>
</div>
</div>
</div>
<script>
// Toggle query examples
function toggleExamples() {
const examples = document.getElementById("queryExamples");
examples.classList.toggle("hidden");
}
// Use example query
function useExample(query) {
document.querySelector('input[name="smart_query"]').value = query;
}
// Use smart query (for buttons)
function useSmartQuery(query) {
document.querySelector('input[name="smart_query"]').value = query;
document.querySelector("form").submit();
}
// Toggle query details
function toggleQueryDetails() {
const details = document.getElementById("queryDetails");
details.classList.toggle("hidden");
}
// Export results
function exportResults() {
const query = encodeURIComponent(
document.querySelector('input[name="smart_query"]').value
);
window.location.href = `/smart-reports/export?smart_query=${query}`;
}
// Clear query
function clearQuery() {
window.location.href = "/smart-reports";
}
// Format column names for display
function formatColumnName(name) {
return name.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
}
// Auto-complete functionality
document.addEventListener("DOMContentLoaded", function () {
const input = document.querySelector('input[name="smart_query"]');
const suggestions = [
"volunteers who went to",
"poll responses for address",
"donations by volunteer",
"team with most appointments",
"people in team",
"money made by team",
"visited addresses",
"active polls",
"appointments for",
"users with role",
"polls with donations over",
"addresses visited by",
"team leads with more than",
"donations per address",
"unvisited addresses with polls",
];
// Simple autocomplete
input.addEventListener("input", function () {
const value = this.value.toLowerCase();
// You could implement autocomplete dropdown here
console.log("Typing:", value);
});
// Submit on Enter
input.addEventListener("keypress", function (e) {
if (e.key === "Enter") {
e.preventDefault();
this.form.submit();
}
});
// Focus on load
input.focus();
});
// Real-time query validation
function validateQuery(query) {
const keywords = [
"volunteer",
"user",
"poll",
"address",
"appointment",
"team",
"donation",
];
const hasKeyword = keywords.some((keyword) =>
query.toLowerCase().includes(keyword)
);
if (!hasKeyword) {
return {
valid: false,
message:
"Query should include at least one entity (volunteer, poll, address, etc.)",
};
}
return { valid: true };
}
// Query suggestions based on context
function getContextualSuggestions(partialQuery) {
const suggestions = [];
const query = partialQuery.toLowerCase();
if (query.includes("volunteer")) {
suggestions.push("volunteers who went to Main Street");
suggestions.push("volunteers with most donations");
suggestions.push("volunteer activity by month");
}
if (query.includes("team")) {
suggestions.push("team with most appointments");
suggestions.push("people in team 1");
suggestions.push("money made by team 2");
}
if (query.includes("address")) {
suggestions.push("addresses visited by volunteer John");
suggestions.push("poll responses for 123 Oak Street");
suggestions.push("unvisited addresses with polls");
}
return suggestions;
}
// Keyboard shortcuts
document.addEventListener("keydown", function (e) {
// Ctrl/Cmd + Enter to submit
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
document.querySelector("form").submit();
}
// Escape to clear
if (e.key === "Escape") {
clearQuery();
}
});
</script>
<style>
/* Enhanced styling for smart interface */
.smart-query-input:focus {
box-shadow: 0 0 0 3px rgba(147, 51, 234, 0.1);
border-color: #9333ea;
}
/* Syntax highlighting for code examples */
code {
background-color: #f3f4f6;
padding: 2px 4px;
border-radius: 3px;
font-family: "Courier New", monospace;
font-size: 0.875em;
}
/* Hover effects for query buttons */
.query-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* Loading animation */
.loading {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Results table styling */
.results-table th {
position: sticky;
top: 0;
background: white;
z-index: 10;
}
/* Print styles */
@media print {
.no-print {
display: none !important;
}
body {
background: white !important;
}
.border-gray-200 {
border-color: #000 !important;
}
}
/* Mobile responsive adjustments */
@media (max-width: 768px) {
.px-8 {
padding-left: 1rem;
padding-right: 1rem;
}
.overflow-x-auto {
-webkit-overflow-scrolling: touch;
}
}
</style>
</body>
</html>
{{ end }}

View File

@@ -196,16 +196,16 @@
</div> </div>
<!-- Delivery Section (conditionally shown) --> <!-- Delivery Section (conditionally shown) -->
<div id="delivery-section" class="hidden"> <div id="delivery-section">
<label <label
class="block text-sm font-medium text-gray-700 mb-2" class="block text-sm font-medium text-gray-700 mb-2"
> >
Delivery Address Donation Amount
</label> </label>
<input <input
type="text" type="number"
name="delivery_address" name="question6_amount"
placeholder="Enter delivery address" placeholder="Enter Donation Amount ($)"
class="professional-input w-full px-3 py-2 border border-gray-300 bg-white text-gray-900 placeholder-gray-500" class="professional-input w-full px-3 py-2 border border-gray-300 bg-white text-gray-900 placeholder-gray-500"
/> />
</div> </div>

View File

@@ -152,12 +152,13 @@ func main() {
http.HandleFunc("/remove_assigned_address", adminMiddleware(handlers.RemoveAssignedAddressHandler)) http.HandleFunc("/remove_assigned_address", adminMiddleware(handlers.RemoveAssignedAddressHandler))
http.HandleFunc("/addresses/upload-csv", adminMiddleware(handlers.CSVUploadHandler)) http.HandleFunc("/addresses/upload-csv", adminMiddleware(handlers.CSVUploadHandler))
http.HandleFunc("/smart-reports", adminMiddleware(handlers.SmartFilterHandler)) http.HandleFunc("/reports", adminMiddleware(handlers.ReportsHandler))
http.HandleFunc("/posts", adminMiddleware(handlers.PostsHandler)) http.HandleFunc("/posts", adminMiddleware(handlers.PostsHandler))
http.HandleFunc("/api/validated-addresses", handlers.GetValidatedAddressesHandler)
http.HandleFunc("/api/validated-addresses/stats", handlers.GetValidatedAddressesStatsHandler)
//--- Volunteer-only routes //--- Volunteer-only routes
http.HandleFunc("/volunteer/dashboard", volunteerMiddleware(handlers.VolunteerPostsHandler)) http.HandleFunc("/volunteer/dashboard", volunteerMiddleware(handlers.VolunteerPostsHandler))
http.HandleFunc("/volunteer/Addresses", volunteerMiddleware(handlers.VolunteerAppointmentHandler)) http.HandleFunc("/volunteer/Addresses", volunteerMiddleware(handlers.VolunteerAppointmentHandler))

View File

@@ -1,2 +1,3 @@
Name,Address,Email,Phone,Phone Name,Address,Email,Phone,Phone
Bob Johnson,1782 cornerstone bv ne,bob@email.com,555-0125,555-0125 Bob Johnson,124 Sage Hill grovev nw,bob@email.com,555-0125,555-0125
1 Name Address Email Phone Phone
2 Bob Johnson 1782 cornerstone bv ne 124 Sage Hill grovev nw bob@email.com 555-0125 555-0125
3

39
misc-code/menu.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<title>Chakra-like Table</title>
</head>
<body class="bg-gray-50 p-10">
<div class="overflow-x-auto">
<table class="min-w-full bg-white border border-gray-200 rounded-lg">
<thead class="bg-teal-500 text-white">
<tr>
<th class="px-6 py-3 text-left">Name</th>
<th class="px-6 py-3 text-left">Age</th>
<th class="px-6 py-3 text-left">City</th>
</tr>
</thead>
<tbody>
<tr class="border-b">
<td class="px-6 py-4">Mann</td>
<td class="px-6 py-4">21</td>
<td class="px-6 py-4">Calgary</td>
</tr>
<tr class="border-b bg-gray-50">
<td class="px-6 py-4">Alice</td>
<td class="px-6 py-4">25</td>
<td class="px-6 py-4">Toronto</td>
</tr>
<tr>
<td class="px-6 py-4">Bob</td>
<td class="px-6 py-4">30</td>
<td class="px-6 py-4">Vancouver</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

1
tmp/build-errors.log Normal file
View File

@@ -0,0 +1 @@
exit status 1