CSV Import is now working
This commit is contained in:
18
README.MD
18
README.MD
@@ -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
141
app/database/schema.sql
Normal 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;
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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, ¤tStatus)
|
|
||||||
|
|
||||||
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
|
// Check if the best match meets our similarity threshold
|
||||||
SET visited_validated = true, updated_at = NOW()
|
if bestMatch.SimilarityScore < threshold {
|
||||||
WHERE address_id = $1
|
result.ErrorCount++
|
||||||
`, addressID)
|
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
|
}
|
||||||
header := []string{
|
|
||||||
"Address ID", "Address", "Street Name", "Street Type", "Street Quadrant",
|
// Normalize the address from database
|
||||||
"House Number", "House Alpha", "Longitude", "Latitude", "Validated",
|
match.Address = normalizeAddress(rawAddress)
|
||||||
"Created At", "Updated At", "Assigned", "Assigned User", "User Email",
|
addresses = append(addresses, match)
|
||||||
"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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
186
app/internal/handlers/admin_map.go
Normal file
186
app/internal/handlers/admin_map.go
Normal 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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
350
app/internal/templates/reports.html
Normal file
350
app/internal/templates/reports.html
Normal 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 }}
|
||||||
@@ -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 }}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
|
39
misc-code/menu.html
Normal file
39
misc-code/menu.html
Normal 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
1
tmp/build-errors.log
Normal file
@@ -0,0 +1 @@
|
|||||||
|
exit status 1
|
||||||
Reference in New Issue
Block a user