feat: validate user availability
This commit is contained in:
16
README.MD
16
README.MD
@@ -1,4 +1,16 @@
|
|||||||
# Poll-system
|
# Poll-system
|
||||||
|
|
||||||
- TODO: Reports Generation, Export csv, Print Pdf, Show Charts
|
- TODO: volunteer Available
|
||||||
- TODO: VOlunteer Schedual or avilablity
|
- TODO: Update assign address func to take into account availability
|
||||||
|
|
||||||
|
|
||||||
|
- 18'' Metal Ruler
|
||||||
|
- Sketching Material (dollaram)(sketch)
|
||||||
|
- Adhesive (staples/dollaram)
|
||||||
|
|
||||||
|
Done:
|
||||||
|
- Exacto
|
||||||
|
- A Large Cutting Mat
|
||||||
|
- Black Construction Paper
|
||||||
|
- And a lock for your locker
|
||||||
|
- White Foam Core or Cardstock Paper (dollaram)
|
||||||
|
|||||||
BIN
app/.DS_Store
vendored
BIN
app/.DS_Store
vendored
Binary file not shown.
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -82,22 +83,22 @@ func AddressHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Query addresses with appointment + user info
|
// Query addresses with appointment + user info
|
||||||
rows, err := models.DB.Query(`
|
rows, err := models.DB.Query(`
|
||||||
SELECT
|
SELECT
|
||||||
a.address_id,
|
a.address_id,
|
||||||
a.address,
|
a.address,
|
||||||
a.street_name,
|
a.street_name,
|
||||||
a.street_type,
|
a.street_type,
|
||||||
a.street_quadrant,
|
a.street_quadrant,
|
||||||
a.house_number,
|
a.house_number,
|
||||||
COALESCE(a.house_alpha, '') as house_alpha,
|
COALESCE(a.house_alpha, '') as house_alpha,
|
||||||
a.longitude,
|
a.longitude,
|
||||||
a.latitude,
|
a.latitude,
|
||||||
a.visited_validated,
|
a.visited_validated,
|
||||||
a.created_at,
|
a.created_at,
|
||||||
a.updated_at,
|
a.updated_at,
|
||||||
CASE
|
CASE
|
||||||
WHEN ap.sched_id IS NOT NULL THEN true
|
WHEN ap.sched_id IS NOT NULL THEN true
|
||||||
ELSE false
|
ELSE false
|
||||||
END as assigned,
|
END as assigned,
|
||||||
ap.user_id,
|
ap.user_id,
|
||||||
COALESCE(u.first_name || ' ' || u.last_name, '') as user_name,
|
COALESCE(u.first_name || ' ' || u.last_name, '') as user_name,
|
||||||
@@ -146,12 +147,12 @@ func AddressHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Println("Scan error:", err)
|
log.Println("Scan error:", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle nullable house_alpha
|
// Handle nullable house_alpha
|
||||||
if houseAlpha != "" {
|
if houseAlpha != "" {
|
||||||
a.HouseAlpha = &houseAlpha
|
a.HouseAlpha = &houseAlpha
|
||||||
}
|
}
|
||||||
|
|
||||||
addresses = append(addresses, a)
|
addresses = append(addresses, a)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,10 +304,13 @@ func AssignAddressHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate the start time
|
// Parse and validate the start time
|
||||||
_, err = time.Parse("15:04", startTime)
|
parsedTime, err := time.Parse("15:04", startTime)
|
||||||
if err != nil {
|
is_valid := ValidatedFreeTime(parsedDate, parsedTime, userID)
|
||||||
http.Error(w, "Invalid start time format", http.StatusBadRequest)
|
if is_valid != true {
|
||||||
|
http.Error(w, "User is not availabile", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
}else{
|
||||||
|
fmt.Print("hello")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the user exists and is associated with the current admin
|
// Verify the user exists and is associated with the current admin
|
||||||
@@ -330,7 +334,7 @@ func AssignAddressHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Check if this address is already assigned to any user
|
// Check if this address is already assigned to any user
|
||||||
var exists int
|
var exists int
|
||||||
err = models.DB.QueryRow(`
|
err = models.DB.QueryRow(`
|
||||||
SELECT COUNT(*) FROM appointment
|
SELECT COUNT(*) FROM appointment
|
||||||
WHERE address_id = $1
|
WHERE address_id = $1
|
||||||
`, addressID).Scan(&exists)
|
`, addressID).Scan(&exists)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -346,7 +350,7 @@ func AssignAddressHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Check if the user already has an appointment at the same date and time
|
// Check if the user already has an appointment at the same date and time
|
||||||
var timeConflict int
|
var timeConflict int
|
||||||
err = models.DB.QueryRow(`
|
err = models.DB.QueryRow(`
|
||||||
SELECT COUNT(*) FROM appointment
|
SELECT COUNT(*) FROM appointment
|
||||||
WHERE user_id = $1 AND appointment_date = $2 AND appointment_time = $3
|
WHERE user_id = $1 AND appointment_date = $2 AND appointment_time = $3
|
||||||
`, userID, appointmentDate, startTime).Scan(&timeConflict)
|
`, userID, appointmentDate, startTime).Scan(&timeConflict)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -410,7 +414,7 @@ func RemoveAssignedAddressHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
currentAdminID := r.Context().Value("user_id").(int)
|
currentAdminID := r.Context().Value("user_id").(int)
|
||||||
var userExists int
|
var userExists int
|
||||||
err = models.DB.QueryRow(`
|
err = models.DB.QueryRow(`
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM admin_volunteers av
|
FROM admin_volunteers av
|
||||||
JOIN appointment ap ON av.volunteer_id = ap.user_id
|
JOIN appointment ap ON av.volunteer_id = ap.user_id
|
||||||
WHERE av.admin_id = $1 AND ap.user_id = $2 AND ap.address_id = $3
|
WHERE av.admin_id = $1 AND ap.user_id = $2 AND ap.address_id = $3
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func AdminDashboardHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// 1. Count volunteers assigned to this admin
|
// 1. Count volunteers assigned to this admin
|
||||||
err := models.DB.QueryRow(`
|
err := models.DB.QueryRow(`
|
||||||
SELECT COUNT(av.volunteer_id)
|
SELECT COUNT(av.volunteer_id)
|
||||||
FROM admin_volunteers av
|
FROM admin_volunteers av
|
||||||
WHERE av.admin_id = $1 AND av.is_active = TRUE;
|
WHERE av.admin_id = $1 AND av.is_active = TRUE;
|
||||||
`, currentAdminID).Scan(&volunteerCount)
|
`, currentAdminID).Scan(&volunteerCount)
|
||||||
@@ -32,7 +32,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_response;
|
FROM poll_response;
|
||||||
`).Scan(&totalDonations)
|
`).Scan(&totalDonations)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -42,7 +42,7 @@ func AdminDashboardHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// 3. Count validated addresses
|
// 3. Count validated addresses
|
||||||
err = models.DB.QueryRow(`
|
err = models.DB.QueryRow(`
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM address_database
|
FROM address_database
|
||||||
WHERE visited_validated = TRUE;
|
WHERE visited_validated = TRUE;
|
||||||
`).Scan(&validatedCount)
|
`).Scan(&validatedCount)
|
||||||
@@ -53,8 +53,8 @@ func AdminDashboardHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// 4. Calculate percentage of houses left to visit
|
// 4. Calculate percentage of houses left to visit
|
||||||
err = models.DB.QueryRow(`
|
err = models.DB.QueryRow(`
|
||||||
SELECT
|
SELECT
|
||||||
CASE
|
CASE
|
||||||
WHEN COUNT(*) = 0 THEN 0
|
WHEN COUNT(*) = 0 THEN 0
|
||||||
ELSE ROUND(
|
ELSE ROUND(
|
||||||
(COUNT(*) FILTER (WHERE visited_validated = FALSE)::numeric / COUNT(*)::numeric) * 100, 2
|
(COUNT(*) FILTER (WHERE visited_validated = FALSE)::numeric / COUNT(*)::numeric) * 100, 2
|
||||||
@@ -79,4 +79,4 @@ func AdminDashboardHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Role": role,
|
"Role": role,
|
||||||
"ActiveSection": "dashboard",
|
"ActiveSection": "dashboard",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,577 +177,425 @@ func getAllReportDefinitions() map[string][]ReportDefinition {
|
|||||||
return map[string][]ReportDefinition{
|
return map[string][]ReportDefinition{
|
||||||
"users": {
|
"users": {
|
||||||
{
|
{
|
||||||
ID: "volunteer_participation_rate", // get all the appointment(done, notdone, total) poll(done, not doen, total)
|
ID: "volunteer_participation_rate",
|
||||||
Name: "Volunteer participation rate",
|
Name: "Volunteer Participation Rate",
|
||||||
Description: "Count of users grouped by their role",
|
Description: "Poll responses and donations collected by each volunteer/team lead",
|
||||||
SQL: `SELECT
|
|
||||||
u.user_id,
|
|
||||||
u.first_name,
|
|
||||||
u.last_name,
|
|
||||||
COUNT(p.poll_id) AS total_polls,
|
|
||||||
COUNT(a.user_id) AS total_appointments,
|
|
||||||
case
|
|
||||||
WHEN COUNT(a.user_id) = 0 THEN NULL -- avoid division by zero
|
|
||||||
ELSE ROUND(CAST(COUNT(p.poll_id) AS numeric) / COUNT(a.user_id), 2)
|
|
||||||
END AS poll_to_appointment_rate
|
|
||||||
from users u
|
|
||||||
LEFT JOIN poll p ON u.user_id = p.user_id
|
|
||||||
LEFT JOIN appointment a ON u.user_id = a.user_id
|
|
||||||
GROUP BY u.user_id, u.first_name, u.last_name;`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "volunteer_activity",
|
|
||||||
Name: "Volunteer Activity Summary",
|
|
||||||
Description: "Summary of volunteer activities including appointments and polls",
|
|
||||||
SQL: `SELECT
|
SQL: `SELECT
|
||||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||||
u.email,
|
u.email,
|
||||||
COUNT(DISTINCT a.sched_id) as appointments_count,
|
r.name as role,
|
||||||
COUNT(DISTINCT p.poll_id) as polls_created,
|
|
||||||
u.created_at as joined_date
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
|
|
||||||
LEFT JOIN poll p ON u.user_id = p.user_id AND p.created_at BETWEEN ?1 AND ?2
|
|
||||||
WHERE u.role_id = 2
|
|
||||||
GROUP BY u.user_id, u.first_name, u.last_name, u.email, u.created_at
|
|
||||||
ORDER BY appointments_count DESC, polls_created DESC`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "team_performance",
|
|
||||||
Name: "Team Performance Report",
|
|
||||||
Description: "Performance metrics for each team",
|
|
||||||
SQL: `SELECT
|
|
||||||
t.team_id,
|
|
||||||
ul.first_name || ' ' || ul.last_name as team_lead,
|
|
||||||
uv.first_name || ' ' || uv.last_name as volunteer,
|
|
||||||
COUNT(DISTINCT a.sched_id) as appointments,
|
|
||||||
COUNT(DISTINCT p.poll_id) as polls_created,
|
|
||||||
t.created_at as team_formed
|
|
||||||
FROM team t
|
|
||||||
LEFT JOIN users ul ON t.team_lead_id = ul.user_id
|
|
||||||
LEFT JOIN users uv ON t.volunteer_id = uv.user_id
|
|
||||||
LEFT JOIN appointment a ON uv.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
|
|
||||||
LEFT JOIN poll p ON uv.user_id = p.user_id AND p.created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY t.team_id, ul.first_name, ul.last_name, uv.first_name, uv.last_name, t.created_at
|
|
||||||
ORDER BY appointments DESC`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "admin_workload",
|
|
||||||
Name: "Admin Workload Analysis",
|
|
||||||
Description: "Workload distribution across admins",
|
|
||||||
SQL: `SELECT
|
|
||||||
u.first_name || ' ' || u.last_name as admin_name,
|
|
||||||
u.email,
|
|
||||||
COUNT(DISTINCT t.team_id) as teams_managed,
|
|
||||||
COUNT(DISTINCT p.poll_id) as polls_created,
|
COUNT(DISTINCT p.poll_id) as polls_created,
|
||||||
|
COUNT(DISTINCT pr.poll_response_id) as responses_collected,
|
||||||
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||||
COUNT(DISTINCT a.sched_id) as appointments_scheduled
|
COUNT(DISTINCT a.sched_id) as appointments_scheduled
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN team t ON u.user_id = t.team_lead_id
|
JOIN role r ON u.role_id = r.role_id
|
||||||
LEFT JOIN poll p ON u.user_id = p.user_id AND p.created_at BETWEEN ?1 AND ?2
|
LEFT JOIN poll p ON u.user_id = p.user_id AND p.created_at BETWEEN ?1 AND ?2
|
||||||
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||||
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
|
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
|
||||||
WHERE u.role_id = 1
|
WHERE u.role_id IN (2, 3)
|
||||||
GROUP BY u.user_id, u.first_name, u.last_name, u.email
|
GROUP BY u.user_id, u.first_name, u.last_name, u.email, r.name
|
||||||
ORDER BY teams_managed DESC, polls_created DESC`,
|
ORDER BY responses_collected DESC, total_donations DESC`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "inactive_users",
|
ID: "top_performing_volunteers",
|
||||||
Name: "Inactive Users Report",
|
Name: "Top-Performing Volunteers & Team Leads",
|
||||||
Description: "Users with no recent activity",
|
Description: "Volunteers ranked by responses collected and donations secured",
|
||||||
SQL: `SELECT
|
SQL: `SELECT
|
||||||
u.first_name || ' ' || u.last_name as user_name,
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||||
u.email,
|
r.name as role,
|
||||||
CASE
|
COUNT(DISTINCT pr.poll_response_id) as responses_collected,
|
||||||
WHEN u.role_id = 1 THEN 'Admin'
|
COALESCE(SUM(pr.question6_donation_amount), 0) as donations_secured,
|
||||||
WHEN u.role_id = 2 THEN 'Volunteer'
|
COUNT(DISTINCT p.poll_id) as polls_created,
|
||||||
ELSE 'Unknown'
|
AVG(pr.question6_donation_amount) as avg_donation_per_poll
|
||||||
END as role,
|
|
||||||
u.created_at as joined_date,
|
|
||||||
COALESCE(MAX(a.created_at), MAX(p.created_at)) as last_activity
|
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN appointment a ON u.user_id = a.user_id
|
JOIN role r ON u.role_id = r.role_id
|
||||||
LEFT JOIN poll p ON u.user_id = p.user_id
|
JOIN poll p ON u.user_id = p.user_id
|
||||||
GROUP BY u.user_id, u.first_name, u.last_name, u.email, u.role_id, u.created_at
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||||
HAVING COALESCE(MAX(a.created_at), MAX(p.created_at)) < ?1 OR COALESCE(MAX(a.created_at), MAX(p.created_at)) IS NULL
|
WHERE u.role_id IN (2, 3) AND p.created_at BETWEEN ?1 AND ?2
|
||||||
ORDER BY last_activity DESC`,
|
GROUP BY u.user_id, u.first_name, u.last_name, r.name
|
||||||
|
HAVING COUNT(DISTINCT pr.poll_response_id) > 0
|
||||||
|
ORDER BY responses_collected DESC, donations_secured DESC
|
||||||
|
LIMIT 20`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "response_donation_ratio",
|
||||||
|
Name: "Response-to-Donation Ratio per Volunteer",
|
||||||
|
Description: "Efficiency measure showing donation amount per response collected",
|
||||||
|
SQL: `SELECT
|
||||||
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||||
|
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
||||||
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(DISTINCT pr.poll_response_id) > 0
|
||||||
|
THEN COALESCE(SUM(pr.question6_donation_amount), 0) / COUNT(DISTINCT pr.poll_response_id)
|
||||||
|
ELSE 0
|
||||||
|
END as donation_per_response,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(SUM(pr.question6_donation_amount), 0) > 0
|
||||||
|
THEN COUNT(DISTINCT pr.poll_response_id) / COALESCE(SUM(pr.question6_donation_amount), 1)
|
||||||
|
ELSE COUNT(DISTINCT pr.poll_response_id)
|
||||||
|
END as responses_per_dollar
|
||||||
|
FROM users u
|
||||||
|
JOIN poll p ON u.user_id = p.user_id
|
||||||
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||||
|
WHERE u.role_id IN (2, 3) AND p.created_at BETWEEN ?1 AND ?2
|
||||||
|
GROUP BY u.user_id, u.first_name, u.last_name
|
||||||
|
HAVING COUNT(DISTINCT pr.poll_response_id) > 0
|
||||||
|
ORDER BY donation_per_response DESC`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "user_address_coverage",
|
||||||
|
Name: "User Address Coverage",
|
||||||
|
Description: "Number of unique addresses covered by each volunteer/team lead",
|
||||||
|
SQL: `SELECT
|
||||||
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||||
|
COUNT(DISTINCT p.address_id) as unique_addresses_polled,
|
||||||
|
COUNT(DISTINCT a.address_id) as unique_addresses_appointed,
|
||||||
|
COUNT(DISTINCT COALESCE(p.address_id, a.address_id)) as total_unique_addresses,
|
||||||
|
COUNT(DISTINCT pr.poll_response_id) as total_responses
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN poll p ON u.user_id = p.user_id AND p.created_at BETWEEN ?1 AND ?2
|
||||||
|
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
|
||||||
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||||
|
WHERE u.role_id IN (2, 3)
|
||||||
|
GROUP BY u.user_id, u.first_name, u.last_name
|
||||||
|
HAVING COUNT(DISTINCT COALESCE(p.address_id, a.address_id)) > 0
|
||||||
|
ORDER BY total_unique_addresses DESC, total_responses DESC`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"addresses": {
|
"addresses": {
|
||||||
{
|
{
|
||||||
ID: "coverage_by_area",
|
ID: "poll_responses_by_address",
|
||||||
Name: "Coverage by Area",
|
Name: "Total Poll Responses by Address",
|
||||||
Description: "Address coverage statistics by geographical area",
|
Description: "Shows engagement hotspots - addresses with most poll responses",
|
||||||
SQL: `SELECT
|
SQL: `SELECT
|
||||||
COALESCE(NULLIF(TRIM(SPLIT_PART(address, ',', -1)), ''), 'Unknown') as area,
|
ad.address,
|
||||||
COUNT(*) as total_addresses,
|
ad.postal_code,
|
||||||
COUNT(CASE WHEN visited_validated = true THEN 1 END) as visited_count,
|
ad.street_quadrant,
|
||||||
ROUND(COUNT(CASE WHEN visited_validated = true THEN 1 END) * 100.0 / COUNT(*), 2) as coverage_percentage
|
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
||||||
FROM address_database
|
COUNT(DISTINCT p.poll_id) as polls_conducted,
|
||||||
WHERE created_at BETWEEN ?1 AND ?2
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||||
GROUP BY area
|
CASE
|
||||||
ORDER BY total_addresses DESC`,
|
WHEN COUNT(DISTINCT pr.poll_response_id) > 0
|
||||||
},
|
THEN COALESCE(SUM(pr.question6_donation_amount), 0) / COUNT(DISTINCT pr.poll_response_id)
|
||||||
{
|
ELSE 0
|
||||||
ID: "visits_by_postal",
|
END as avg_donation_per_response
|
||||||
Name: "Visits by Postal Code",
|
FROM address_database ad
|
||||||
Description: "Visit statistics grouped by postal code",
|
JOIN poll p ON ad.address_id = p.address_id
|
||||||
SQL: `SELECT
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||||
COALESCE(NULLIF(TRIM(SUBSTRING(address FROM '[A-Za-z][0-9][A-Za-z] ?[0-9][A-Za-z][0-9]')), ''), 'No Postal Code') as postal_code,
|
WHERE p.created_at BETWEEN ?1 AND ?2
|
||||||
COUNT(*) as addresses,
|
GROUP BY ad.address_id, ad.address, ad.postal_code, ad.street_quadrant
|
||||||
COUNT(CASE WHEN visited_validated = true THEN 1 END) as visited,
|
ORDER BY total_responses DESC, total_donations DESC
|
||||||
COUNT(CASE WHEN visited_validated = false THEN 1 END) as unvisited
|
|
||||||
FROM address_database
|
|
||||||
WHERE created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY postal_code
|
|
||||||
ORDER BY addresses DESC
|
|
||||||
LIMIT 50`,
|
LIMIT 50`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "unvisited_addresses",
|
ID: "donations_by_address",
|
||||||
Name: "Unvisited Addresses",
|
Name: "Total Donations by Address",
|
||||||
Description: "List of addresses that haven't been visited",
|
Description: "Shows financially supportive areas - addresses with highest donations",
|
||||||
SQL: `SELECT
|
SQL: `SELECT
|
||||||
address_id,
|
ad.address,
|
||||||
address,
|
ad.postal_code,
|
||||||
latitude,
|
ad.street_quadrant,
|
||||||
longitude,
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||||
created_at as added_date
|
COUNT(DISTINCT p.poll_id) as polls_conducted,
|
||||||
FROM address_database
|
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
||||||
WHERE visited_validated = false
|
AVG(pr.question6_donation_amount) as avg_donation_per_poll
|
||||||
AND created_at BETWEEN ?1 AND ?2
|
FROM address_database ad
|
||||||
ORDER BY created_at DESC
|
JOIN poll p ON ad.address_id = p.address_id
|
||||||
LIMIT 100`,
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||||
},
|
WHERE p.created_at BETWEEN ?1 AND ?2 AND pr.question6_donation_amount > 0
|
||||||
{
|
GROUP BY ad.address_id, ad.address, ad.postal_code, ad.street_quadrant
|
||||||
ID: "donations_by_location",
|
|
||||||
Name: "Donations by Location",
|
|
||||||
Description: "Donation amounts grouped by address location",
|
|
||||||
SQL: `SELECT
|
|
||||||
a.address,
|
|
||||||
COUNT(p.poll_id) as total_polls,
|
|
||||||
COALESCE(SUM(p.amount_donated), 0) as total_donations,
|
|
||||||
COALESCE(AVG(p.amount_donated), 0) as avg_donation
|
|
||||||
FROM address_database a
|
|
||||||
LEFT JOIN poll p ON a.address_id = p.address_id AND p.created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY a.address_id, a.address
|
|
||||||
HAVING COUNT(p.poll_id) > 0
|
|
||||||
ORDER BY total_donations DESC
|
ORDER BY total_donations DESC
|
||||||
LIMIT 50`,
|
LIMIT 50`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "address_validation_status",
|
ID: "street_level_breakdown",
|
||||||
Name: "Address Validation Status",
|
Name: "Street-Level Breakdown (Responses & Donations)",
|
||||||
Description: "Status of address validation across the database",
|
Description: "Granular view for targeting - responses and donations by street",
|
||||||
SQL: `SELECT
|
SQL: `SELECT
|
||||||
CASE
|
ad.street_name,
|
||||||
WHEN visited_validated = true THEN 'Validated'
|
ad.street_type,
|
||||||
WHEN visited_validated = false THEN 'Not Validated'
|
ad.street_quadrant,
|
||||||
ELSE 'Unknown'
|
COUNT(DISTINCT ad.address_id) as unique_addresses,
|
||||||
END as validation_status,
|
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
||||||
COUNT(*) as address_count,
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||||
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM address_database), 2) as percentage
|
COUNT(DISTINCT p.poll_id) as polls_conducted
|
||||||
FROM address_database
|
FROM address_database ad
|
||||||
WHERE created_at BETWEEN ?1 AND ?2
|
LEFT JOIN poll p ON ad.address_id = p.address_id AND p.created_at BETWEEN ?1 AND ?2
|
||||||
GROUP BY visited_validated
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||||
ORDER BY address_count DESC`,
|
WHERE ad.street_name IS NOT NULL
|
||||||
|
GROUP BY ad.street_name, ad.street_type, ad.street_quadrant
|
||||||
|
HAVING COUNT(DISTINCT pr.poll_response_id) > 0 OR COALESCE(SUM(pr.question6_donation_amount), 0) > 0
|
||||||
|
ORDER BY total_responses DESC, total_donations DESC`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "quadrant_summary",
|
||||||
|
Name: "Quadrant-Level Summary (NE, NW, SE, SW)",
|
||||||
|
Description: "Higher-level trend view by city quadrants",
|
||||||
|
SQL: `SELECT
|
||||||
|
COALESCE(ad.street_quadrant, 'Unknown') as quadrant,
|
||||||
|
COUNT(DISTINCT ad.address_id) as unique_addresses,
|
||||||
|
COUNT(DISTINCT p.poll_id) as polls_conducted,
|
||||||
|
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
||||||
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||||
|
AVG(pr.question6_donation_amount) as avg_donation_per_poll,
|
||||||
|
COUNT(DISTINCT a.sched_id) as appointments_scheduled
|
||||||
|
FROM address_database ad
|
||||||
|
LEFT JOIN poll p ON ad.address_id = p.address_id AND p.created_at BETWEEN ?1 AND ?2
|
||||||
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||||
|
LEFT JOIN appointment a ON ad.address_id = a.address_id AND a.created_at BETWEEN ?1 AND ?2
|
||||||
|
GROUP BY ad.street_quadrant
|
||||||
|
ORDER BY total_responses DESC, total_donations DESC`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"appointments": {
|
"appointments": {
|
||||||
{
|
{
|
||||||
ID: "appointments_by_day",
|
ID: "upcoming_appointments",
|
||||||
Name: "Appointments by Day",
|
Name: "Upcoming Appointments per Volunteer/Team Lead",
|
||||||
Description: "Daily breakdown of appointment scheduling",
|
Description: "Scheduling load - upcoming appointments by user",
|
||||||
SQL: `SELECT
|
|
||||||
appointment_date,
|
|
||||||
COUNT(*) as appointments_scheduled,
|
|
||||||
COUNT(DISTINCT user_id) as unique_volunteers,
|
|
||||||
COUNT(DISTINCT address_id) as unique_addresses
|
|
||||||
FROM appointment
|
|
||||||
WHERE appointment_date BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY appointment_date
|
|
||||||
ORDER BY appointment_date DESC`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "completion_rates",
|
|
||||||
Name: "Completion Rates",
|
|
||||||
Description: "Appointment completion statistics by volunteer",
|
|
||||||
SQL: `SELECT
|
SQL: `SELECT
|
||||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||||
COUNT(a.sched_id) as total_appointments,
|
r.name as role,
|
||||||
COUNT(CASE WHEN ad.visited_validated = true THEN 1 END) as completed_visits,
|
COUNT(*) as upcoming_appointments,
|
||||||
ROUND(COUNT(CASE WHEN ad.visited_validated = true THEN 1 END) * 100.0 / COUNT(a.sched_id), 2) as completion_rate
|
MIN(a.appointment_date) as earliest_appointment,
|
||||||
|
MAX(a.appointment_date) as latest_appointment,
|
||||||
|
COUNT(CASE WHEN a.appointment_date = CURRENT_DATE THEN 1 END) as today_appointments,
|
||||||
|
COUNT(CASE WHEN a.appointment_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '7 days' THEN 1 END) as week_appointments
|
||||||
|
FROM appointment a
|
||||||
|
JOIN users u ON a.user_id = u.user_id
|
||||||
|
JOIN role r ON u.role_id = r.role_id
|
||||||
|
WHERE a.appointment_date >= CURRENT_DATE AND a.appointment_date BETWEEN ?1 AND ?2
|
||||||
|
GROUP BY u.user_id, u.first_name, u.last_name, r.name
|
||||||
|
ORDER BY upcoming_appointments DESC`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "missed_vs_completed",
|
||||||
|
Name: "Missed vs Completed Appointments",
|
||||||
|
Description: "Reliability metric - appointment completion rates",
|
||||||
|
SQL: `SELECT
|
||||||
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||||
|
COUNT(*) as total_appointments,
|
||||||
|
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END) as past_appointments,
|
||||||
|
COUNT(CASE WHEN a.appointment_date >= CURRENT_DATE THEN 1 END) as upcoming_appointments,
|
||||||
|
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
|
||||||
|
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
||||||
|
AND DATE(p.created_at) = a.appointment_date
|
||||||
|
) THEN 1 END) as completed_appointments,
|
||||||
|
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
||||||
|
AND DATE(p.created_at) = a.appointment_date
|
||||||
|
) THEN 1 END) as missed_appointments,
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END) > 0
|
||||||
|
THEN COUNT(CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
|
||||||
|
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
||||||
|
AND DATE(p.created_at) = a.appointment_date
|
||||||
|
) THEN 1 END) * 100.0 / COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END)
|
||||||
|
ELSE 0
|
||||||
|
END as completion_rate_percent
|
||||||
FROM appointment a
|
FROM appointment a
|
||||||
JOIN users u ON a.user_id = u.user_id
|
JOIN users u ON a.user_id = u.user_id
|
||||||
LEFT JOIN address_database ad ON a.address_id = ad.address_id
|
|
||||||
WHERE a.created_at BETWEEN ?1 AND ?2
|
WHERE a.created_at BETWEEN ?1 AND ?2
|
||||||
GROUP BY u.user_id, u.first_name, u.last_name
|
GROUP BY u.user_id, u.first_name, u.last_name
|
||||||
HAVING COUNT(a.sched_id) > 0
|
ORDER BY completion_rate_percent DESC, total_appointments DESC`,
|
||||||
ORDER BY completion_rate DESC, total_appointments DESC`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "volunteer_schedules",
|
ID: "appointments_by_quadrant",
|
||||||
Name: "Volunteer Schedules",
|
Name: "Appointments by Quadrant/Region",
|
||||||
Description: "Current volunteer scheduling overview",
|
Description: "Geographic distribution of appointments",
|
||||||
|
SQL: `SELECT
|
||||||
|
COALESCE(ad.street_quadrant, 'Unknown') as quadrant,
|
||||||
|
COUNT(*) as total_appointments,
|
||||||
|
COUNT(CASE WHEN a.appointment_date >= CURRENT_DATE THEN 1 END) as upcoming,
|
||||||
|
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END) as past,
|
||||||
|
COUNT(DISTINCT a.user_id) as unique_volunteers,
|
||||||
|
COUNT(DISTINCT a.address_id) as unique_addresses
|
||||||
|
FROM appointment a
|
||||||
|
JOIN address_database ad ON a.address_id = ad.address_id
|
||||||
|
WHERE a.created_at BETWEEN ?1 AND ?2
|
||||||
|
GROUP BY ad.street_quadrant
|
||||||
|
ORDER BY total_appointments DESC`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "scheduling_lead_time",
|
||||||
|
Name: "Average Lead Time (Scheduled vs Actual Date)",
|
||||||
|
Description: "Scheduling efficiency - time between scheduling and appointment",
|
||||||
SQL: `SELECT
|
SQL: `SELECT
|
||||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||||
a.appointment_date,
|
COUNT(*) as total_appointments,
|
||||||
a.appointment_time,
|
AVG(a.appointment_date - DATE(a.created_at)) as avg_lead_time_days,
|
||||||
ad.address,
|
MIN(a.appointment_date - DATE(a.created_at)) as min_lead_time_days,
|
||||||
a.created_at as scheduled_date
|
MAX(a.appointment_date - DATE(a.created_at)) as max_lead_time_days,
|
||||||
|
COUNT(CASE WHEN a.appointment_date - DATE(a.created_at) < 1 THEN 1 END) as same_day_appointments,
|
||||||
|
COUNT(CASE WHEN a.appointment_date - DATE(a.created_at) BETWEEN 1 AND 7 THEN 1 END) as week_ahead_appointments
|
||||||
FROM appointment a
|
FROM appointment a
|
||||||
JOIN users u ON a.user_id = u.user_id
|
JOIN users u ON a.user_id = u.user_id
|
||||||
JOIN address_database ad ON a.address_id = ad.address_id
|
WHERE a.created_at BETWEEN ?1 AND ?2
|
||||||
WHERE a.appointment_date BETWEEN ?1 AND ?2
|
GROUP BY u.user_id, u.first_name, u.last_name
|
||||||
ORDER BY a.appointment_date, a.appointment_time`,
|
HAVING COUNT(*) > 0
|
||||||
},
|
ORDER BY avg_lead_time_days ASC`,
|
||||||
{
|
|
||||||
ID: "missed_appointments",
|
|
||||||
Name: "Missed Appointments",
|
|
||||||
Description: "Appointments that were scheduled but addresses remain unvisited",
|
|
||||||
SQL: `SELECT
|
|
||||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
|
||||||
a.appointment_date,
|
|
||||||
a.appointment_time,
|
|
||||||
ad.address,
|
|
||||||
CASE
|
|
||||||
WHEN a.appointment_date < CURRENT_DATE THEN 'Overdue'
|
|
||||||
ELSE 'Upcoming'
|
|
||||||
END as status
|
|
||||||
FROM appointment a
|
|
||||||
JOIN users u ON a.user_id = u.user_id
|
|
||||||
JOIN address_database ad ON a.address_id = ad.address_id
|
|
||||||
WHERE ad.visited_validated = false
|
|
||||||
AND a.appointment_date BETWEEN ?1 AND ?2
|
|
||||||
ORDER BY a.appointment_date DESC`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "peak_hours",
|
|
||||||
Name: "Peak Activity Hours",
|
|
||||||
Description: "Most popular appointment times",
|
|
||||||
SQL: `SELECT
|
|
||||||
appointment_time,
|
|
||||||
COUNT(*) as appointment_count,
|
|
||||||
COUNT(DISTINCT user_id) as unique_volunteers
|
|
||||||
FROM appointment
|
|
||||||
WHERE appointment_date BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY appointment_time
|
|
||||||
ORDER BY appointment_count DESC`,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"polls": {
|
"polls": {
|
||||||
{
|
{
|
||||||
ID: "poll_creation_stats",
|
ID: "response_distribution",
|
||||||
Name: "Poll Creation Statistics",
|
Name: "Response Distribution (Yes/No/Neutral)",
|
||||||
Description: "Overview of poll creation activity",
|
Description: "Outcome summary - distribution of poll responses",
|
||||||
SQL: `SELECT
|
|
||||||
u.first_name || ' ' || u.last_name as creator_name,
|
|
||||||
COUNT(p.poll_id) as polls_created,
|
|
||||||
COUNT(CASE WHEN p.is_active = true THEN 1 END) as active_polls,
|
|
||||||
COALESCE(SUM(p.amount_donated), 0) as total_donations,
|
|
||||||
COALESCE(AVG(p.amount_donated), 0) as avg_donation_per_poll
|
|
||||||
FROM poll p
|
|
||||||
JOIN users u ON p.user_id = u.user_id
|
|
||||||
WHERE p.created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY u.user_id, u.first_name, u.last_name
|
|
||||||
ORDER BY polls_created DESC`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "donation_analysis",
|
|
||||||
Name: "Donation Analysis",
|
|
||||||
Description: "Detailed analysis of donation patterns",
|
|
||||||
SQL: `SELECT
|
SQL: `SELECT
|
||||||
CASE
|
CASE
|
||||||
WHEN amount_donated = 0 THEN 'No Donation'
|
WHEN question1_voted_before = true AND question2_vote_again = true THEN 'Previous Voter - Will Vote Again'
|
||||||
WHEN amount_donated BETWEEN 0.01 AND 25 THEN '$1 - $25'
|
WHEN question1_voted_before = true AND question2_vote_again = false THEN 'Previous Voter - Will Not Vote Again'
|
||||||
WHEN amount_donated BETWEEN 25.01 AND 50 THEN '$26 - $50'
|
WHEN question1_voted_before = false AND question2_vote_again = true THEN 'New Voter - Will Vote'
|
||||||
WHEN amount_donated BETWEEN 50.01 AND 100 THEN '$51 - $100'
|
WHEN question1_voted_before = false AND question2_vote_again = false THEN 'New Voter - Will Not Vote'
|
||||||
ELSE 'Over $100'
|
WHEN question1_voted_before IS NULL OR question2_vote_again IS NULL THEN 'Incomplete Response'
|
||||||
END as donation_range,
|
END as response_category,
|
||||||
COUNT(*) as poll_count,
|
|
||||||
COALESCE(SUM(amount_donated), 0) as total_amount,
|
|
||||||
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM poll WHERE created_at BETWEEN ?1 AND ?2), 2) as percentage
|
|
||||||
FROM poll
|
|
||||||
WHERE created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY donation_range
|
|
||||||
ORDER BY
|
|
||||||
CASE donation_range
|
|
||||||
WHEN 'No Donation' THEN 1
|
|
||||||
WHEN '$1 - $25' THEN 2
|
|
||||||
WHEN '$26 - $50' THEN 3
|
|
||||||
WHEN '$51 - $100' THEN 4
|
|
||||||
WHEN 'Over $100' THEN 5
|
|
||||||
END`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "active_vs_inactive",
|
|
||||||
Name: "Active vs Inactive Polls",
|
|
||||||
Description: "Comparison of active and inactive polls",
|
|
||||||
SQL: `SELECT
|
|
||||||
CASE
|
|
||||||
WHEN is_active = true THEN 'Active'
|
|
||||||
ELSE 'Inactive'
|
|
||||||
END as poll_status,
|
|
||||||
COUNT(*) as poll_count,
|
|
||||||
COALESCE(SUM(amount_donated), 0) as total_donations,
|
|
||||||
COALESCE(AVG(amount_donated), 0) as avg_donation
|
|
||||||
FROM poll
|
|
||||||
WHERE created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY is_active
|
|
||||||
ORDER BY poll_count DESC`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "poll_trends",
|
|
||||||
Name: "Poll Activity Trends",
|
|
||||||
Description: "Poll creation trends over time",
|
|
||||||
SQL: `SELECT
|
|
||||||
DATE(created_at) as creation_date,
|
|
||||||
COUNT(*) as polls_created,
|
|
||||||
COUNT(CASE WHEN is_active = true THEN 1 END) as active_polls,
|
|
||||||
COALESCE(SUM(amount_donated), 0) as daily_donations
|
|
||||||
FROM poll
|
|
||||||
WHERE created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY DATE(created_at)
|
|
||||||
ORDER BY creation_date DESC`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "creator_performance",
|
|
||||||
Name: "Creator Performance",
|
|
||||||
Description: "Performance metrics for poll creators",
|
|
||||||
SQL: `SELECT
|
|
||||||
u.first_name || ' ' || u.last_name as creator_name,
|
|
||||||
u.email,
|
|
||||||
COUNT(p.poll_id) as total_polls,
|
|
||||||
COALESCE(SUM(p.amount_donated), 0) as total_raised,
|
|
||||||
COALESCE(MAX(p.amount_donated), 0) as highest_donation,
|
|
||||||
COUNT(CASE WHEN p.is_active = true THEN 1 END) as active_polls
|
|
||||||
FROM users u
|
|
||||||
JOIN poll p ON u.user_id = p.user_id
|
|
||||||
WHERE p.created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY u.user_id, u.first_name, u.last_name, u.email
|
|
||||||
ORDER BY total_raised DESC, total_polls DESC`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
{
|
|
||||||
ID: "voter_status",
|
|
||||||
Name: "Voter Status Report",
|
|
||||||
Description: "Analysis of voter status from poll responses",
|
|
||||||
SQL: `SELECT
|
|
||||||
voter_before as voted_before,
|
|
||||||
COUNT(*) as response_count,
|
COUNT(*) as response_count,
|
||||||
COUNT(CASE WHEN will_vote_again = true THEN 1 END) as will_vote_again_count,
|
COUNT(*) * 100.0 / (SELECT COUNT(*) FROM poll_response pr2
|
||||||
ROUND(COUNT(CASE WHEN will_vote_again = true THEN 1 END) * 100.0 / COUNT(*), 2) as vote_again_percentage
|
JOIN poll p2 ON pr2.poll_id = p2.poll_id
|
||||||
|
WHERE p2.created_at BETWEEN ?1 AND ?2) as percentage
|
||||||
FROM poll_response pr
|
FROM poll_response pr
|
||||||
JOIN poll p ON pr.poll_id = p.poll_id
|
JOIN poll p ON pr.poll_id = p.poll_id
|
||||||
WHERE p.created_at BETWEEN ?1 AND ?2
|
WHERE p.created_at BETWEEN ?1 AND ?2
|
||||||
GROUP BY voter_before
|
GROUP BY response_category
|
||||||
ORDER BY response_count DESC`,
|
ORDER BY response_count DESC`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "sign_requests",
|
ID: "average_poll_response",
|
||||||
Name: "Sign Requests Summary",
|
Name: "Average Poll Response (Yes/No %)",
|
||||||
Description: "Summary of lawn sign and banner requests",
|
Description: "Overall sentiment - percentage breakdown of responses",
|
||||||
SQL: `SELECT
|
SQL: `SELECT
|
||||||
'Lawn Signs' as sign_type,
|
'Previous Voters' as voter_type,
|
||||||
SUM(lawn_sign) as total_requested,
|
COUNT(*) as total_responses,
|
||||||
SUM(CASE WHEN lawn_sign_status = 'delivered' THEN lawn_sign ELSE 0 END) as delivered,
|
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) as positive_responses,
|
||||||
SUM(CASE WHEN lawn_sign_status = 'cancelled' THEN lawn_sign ELSE 0 END) as cancelled
|
COUNT(CASE WHEN question2_vote_again = false THEN 1 END) as negative_responses,
|
||||||
|
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) * 100.0 / COUNT(*) as positive_percentage
|
||||||
FROM poll_response pr
|
FROM poll_response pr
|
||||||
JOIN poll p ON pr.poll_id = p.poll_id
|
JOIN poll p ON pr.poll_id = p.poll_id
|
||||||
WHERE p.created_at BETWEEN ?1 AND ?2
|
WHERE p.created_at BETWEEN ?1 AND ?2 AND question1_voted_before = true
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT
|
SELECT
|
||||||
'Banner Signs' as sign_type,
|
'New Voters' as voter_type,
|
||||||
SUM(banner_sign) as total_requested,
|
COUNT(*) as total_responses,
|
||||||
SUM(CASE WHEN banner_sign_status = 'delivered' THEN banner_sign ELSE 0 END) as delivered,
|
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) as positive_responses,
|
||||||
SUM(CASE WHEN banner_sign_status = 'cancelled' THEN banner_sign ELSE 0 END) as cancelled
|
COUNT(CASE WHEN question2_vote_again = false THEN 1 END) as negative_responses,
|
||||||
|
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) * 100.0 / COUNT(*) as positive_percentage
|
||||||
|
FROM poll_response pr
|
||||||
|
JOIN poll p ON pr.poll_id = p.poll_id
|
||||||
|
WHERE p.created_at BETWEEN ?1 AND ?2 AND question1_voted_before = false
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'Overall' as voter_type,
|
||||||
|
COUNT(*) as total_responses,
|
||||||
|
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) as positive_responses,
|
||||||
|
COUNT(CASE WHEN question2_vote_again = false THEN 1 END) as negative_responses,
|
||||||
|
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) * 100.0 / COUNT(*) as positive_percentage
|
||||||
FROM poll_response pr
|
FROM poll_response pr
|
||||||
JOIN poll p ON pr.poll_id = p.poll_id
|
JOIN poll p ON pr.poll_id = p.poll_id
|
||||||
WHERE p.created_at BETWEEN ?1 AND ?2`,
|
WHERE p.created_at BETWEEN ?1 AND ?2`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "feedback_analysis",
|
ID: "donations_by_poll",
|
||||||
Name: "Feedback Analysis",
|
Name: "Donations by Poll",
|
||||||
Description: "Analysis of open-text feedback from responses",
|
Description: "Which polls drive donations - donation amounts per poll",
|
||||||
SQL: `SELECT
|
SQL: `SELECT
|
||||||
LENGTH(thoughts) as feedback_length_category,
|
p.poll_id,
|
||||||
COUNT(*) as response_count
|
u.first_name || ' ' || u.last_name as creator_name,
|
||||||
|
ad.address,
|
||||||
|
pr.question6_donation_amount,
|
||||||
|
COUNT(pr.poll_response_id) as response_count,
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(pr.poll_response_id) > 0
|
||||||
|
THEN pr.question6_donation_amount / COUNT(pr.poll_response_id)
|
||||||
|
ELSE 0
|
||||||
|
END as donation_per_response,
|
||||||
|
p.created_at as poll_date
|
||||||
|
FROM poll p
|
||||||
|
JOIN users u ON p.user_id = u.user_id
|
||||||
|
JOIN address_database ad ON p.address_id = ad.address_id
|
||||||
|
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||||
|
WHERE p.created_at BETWEEN ?1 AND ?2 AND pr.question6_donation_amount > 0
|
||||||
|
GROUP BY p.poll_id, u.first_name, u.last_name, ad.address, pr.question6_donation_amount, p.created_at
|
||||||
|
ORDER BY pr.question6_donation_amount DESC, response_count DESC`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "response_donation_correlation",
|
||||||
|
Name: "Response-to-Donation Correlation",
|
||||||
|
Description: "Are positive responses linked to donations?",
|
||||||
|
SQL: `SELECT
|
||||||
|
CASE
|
||||||
|
WHEN question2_vote_again = true THEN 'Will Vote Again'
|
||||||
|
WHEN question2_vote_again = false THEN 'Will Not Vote Again'
|
||||||
|
ELSE 'No Response'
|
||||||
|
END as response_type,
|
||||||
|
COUNT(*) as response_count,
|
||||||
|
COUNT(CASE WHEN pr.question6_donation_amount > 0 THEN 1 END) as responses_with_donations,
|
||||||
|
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||||
|
AVG(pr.question6_donation_amount) as avg_donation,
|
||||||
|
COUNT(CASE WHEN pr.question6_donation_amount > 0 THEN 1 END) * 100.0 / COUNT(*) as donation_rate_percent
|
||||||
FROM poll_response pr
|
FROM poll_response pr
|
||||||
JOIN poll p ON pr.poll_id = p.poll_id
|
JOIN poll p ON pr.poll_id = p.poll_id
|
||||||
WHERE p.created_at BETWEEN ?1 AND ?2
|
WHERE p.created_at BETWEEN ?1 AND ?2
|
||||||
AND thoughts IS NOT NULL
|
GROUP BY question2_vote_again
|
||||||
AND TRIM(thoughts) != ''
|
ORDER BY total_donations DESC`,
|
||||||
GROUP BY
|
|
||||||
CASE
|
|
||||||
WHEN LENGTH(thoughts) <= 50 THEN 'Short (1-50 chars)'
|
|
||||||
WHEN LENGTH(thoughts) <= 150 THEN 'Medium (51-150 chars)'
|
|
||||||
ELSE 'Long (150+ chars)'
|
|
||||||
END
|
|
||||||
ORDER BY response_count DESC`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "response_trends",
|
|
||||||
Name: "Response Trends",
|
|
||||||
Description: "Poll response trends over time",
|
|
||||||
SQL: `SELECT
|
|
||||||
DATE(pr.created_at) as response_date,
|
|
||||||
COUNT(*) as responses,
|
|
||||||
COUNT(CASE WHEN voter_before = true THEN 1 END) as returning_voters,
|
|
||||||
COUNT(CASE WHEN will_vote_again = true THEN 1 END) as committed_future_voters
|
|
||||||
FROM poll_response pr
|
|
||||||
JOIN poll p ON pr.poll_id = p.poll_id
|
|
||||||
WHERE pr.created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY DATE(pr.created_at)
|
|
||||||
ORDER BY response_date DESC`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "repeat_voters",
|
|
||||||
Name: "Repeat Voters Analysis",
|
|
||||||
Description: "Analysis of voters who have responded to multiple polls",
|
|
||||||
SQL: `SELECT
|
|
||||||
pr.name,
|
|
||||||
pr.email,
|
|
||||||
COUNT(DISTINCT pr.poll_id) as polls_responded,
|
|
||||||
SUM(CASE WHEN voter_before = true THEN 1 ELSE 0 END) as times_voted_before,
|
|
||||||
SUM(CASE WHEN will_vote_again = true THEN 1 ELSE 0 END) as times_will_vote_again
|
|
||||||
FROM poll_response pr
|
|
||||||
JOIN poll p ON pr.poll_id = p.poll_id
|
|
||||||
WHERE p.created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY pr.name, pr.email
|
|
||||||
HAVING COUNT(DISTINCT pr.poll_id) > 1
|
|
||||||
ORDER BY polls_responded DESC`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"posts": {
|
|
||||||
{
|
|
||||||
ID: "posts_by_user",
|
|
||||||
Name: "Posts by User",
|
|
||||||
Description: "Post creation statistics by user",
|
|
||||||
SQL: `SELECT
|
|
||||||
u.first_name || ' ' || u.last_name as author_name,
|
|
||||||
u.email,
|
|
||||||
COUNT(po.post_id) as total_posts,
|
|
||||||
MIN(po.created_at) as first_post,
|
|
||||||
MAX(po.created_at) as latest_post
|
|
||||||
FROM users u
|
|
||||||
JOIN posts po ON u.user_id = po.user_id
|
|
||||||
WHERE po.created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY u.user_id, u.first_name, u.last_name, u.email
|
|
||||||
ORDER BY total_posts DESC`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "engagement_timeline",
|
|
||||||
Name: "Engagement Timeline",
|
|
||||||
Description: "Post creation timeline",
|
|
||||||
SQL: `SELECT
|
|
||||||
DATE(created_at) as post_date,
|
|
||||||
COUNT(*) as posts_created,
|
|
||||||
COUNT(DISTINCT user_id) as active_users
|
|
||||||
FROM posts
|
|
||||||
WHERE created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY DATE(created_at)
|
|
||||||
ORDER BY post_date DESC`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "content_analysis",
|
|
||||||
Name: "Content Analysis",
|
|
||||||
Description: "Analysis of post content length and characteristics",
|
|
||||||
SQL: `SELECT
|
|
||||||
CASE
|
|
||||||
WHEN LENGTH(content) <= 100 THEN 'Short (1-100 chars)'
|
|
||||||
WHEN LENGTH(content) <= 300 THEN 'Medium (101-300 chars)'
|
|
||||||
ELSE 'Long (300+ chars)'
|
|
||||||
END as content_length,
|
|
||||||
COUNT(*) as post_count,
|
|
||||||
ROUND(AVG(LENGTH(content)), 2) as avg_length
|
|
||||||
FROM posts
|
|
||||||
WHERE created_at BETWEEN ?1 AND ?2
|
|
||||||
AND content IS NOT NULL
|
|
||||||
GROUP BY content_length
|
|
||||||
ORDER BY post_count DESC`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "post_frequency",
|
|
||||||
Name: "Post Frequency Report",
|
|
||||||
Description: "Posting frequency patterns",
|
|
||||||
SQL: `SELECT
|
|
||||||
u.first_name || ' ' || u.last_name as author_name,
|
|
||||||
COUNT(*) as total_posts,
|
|
||||||
ROUND(COUNT(*) * 1.0 / GREATEST(1, EXTRACT(days FROM (?2::date - ?1::date))), 2) as posts_per_day,
|
|
||||||
DATE(MIN(po.created_at)) as first_post,
|
|
||||||
DATE(MAX(po.created_at)) as last_post
|
|
||||||
FROM posts po
|
|
||||||
JOIN users u ON po.user_id = u.user_id
|
|
||||||
WHERE po.created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY u.user_id, u.first_name, u.last_name
|
|
||||||
HAVING COUNT(*) > 1
|
|
||||||
ORDER BY posts_per_day DESC`,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"availability": {
|
"availability": {
|
||||||
{
|
{
|
||||||
ID: "volunteer_availability",
|
ID: "volunteer_availability_schedule",
|
||||||
Name: "Volunteer Availability",
|
Name: "Volunteer Availability by Date Range",
|
||||||
Description: "Current volunteer availability schedules",
|
Description: "Who can work when - current volunteer availability schedules",
|
||||||
SQL: `SELECT
|
SQL: `SELECT
|
||||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||||
|
u.email,
|
||||||
av.day_of_week,
|
av.day_of_week,
|
||||||
av.start_time,
|
av.start_time,
|
||||||
av.end_time,
|
av.end_time,
|
||||||
|
EXTRACT(EPOCH FROM (av.end_time - av.start_time))/3600 as hours_available,
|
||||||
av.created_at as schedule_updated
|
av.created_at as schedule_updated
|
||||||
FROM volunteer_availability av
|
FROM availability av
|
||||||
JOIN users u ON av.user_id = u.user_id
|
JOIN users u ON av.user_id = u.user_id
|
||||||
WHERE av.created_at BETWEEN ?1 AND ?2
|
WHERE u.role_id IN (2, 3) AND av.created_at BETWEEN ?1 AND ?2
|
||||||
ORDER BY u.first_name, u.last_name, av.day_of_week, av.start_time`,
|
ORDER BY u.first_name, u.last_name,
|
||||||
|
CASE av.day_of_week
|
||||||
|
WHEN 'Monday' THEN 1
|
||||||
|
WHEN 'Tuesday' THEN 2
|
||||||
|
WHEN 'Wednesday' THEN 3
|
||||||
|
WHEN 'Thursday' THEN 4
|
||||||
|
WHEN 'Friday' THEN 5
|
||||||
|
WHEN 'Saturday' THEN 6
|
||||||
|
WHEN 'Sunday' THEN 7
|
||||||
|
END, av.start_time`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "peak_availability",
|
ID: "volunteer_fulfillment",
|
||||||
Name: "Peak Availability Times",
|
Name: "Volunteer Fulfillment (Available vs Actually Worked)",
|
||||||
Description: "Times when most volunteers are available",
|
Description: "Reliability measure - comparing availability to actual appointments",
|
||||||
SQL: `SELECT
|
|
||||||
day_of_week,
|
|
||||||
start_time,
|
|
||||||
end_time,
|
|
||||||
COUNT(*) as volunteers_available
|
|
||||||
FROM volunteer_availability av
|
|
||||||
JOIN users u ON av.user_id = u.user_id
|
|
||||||
WHERE av.created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY day_of_week, start_time, end_time
|
|
||||||
ORDER BY volunteers_available DESC, day_of_week, start_time`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "coverage_gaps",
|
|
||||||
Name: "Coverage Gaps",
|
|
||||||
Description: "Time periods with limited volunteer availability",
|
|
||||||
SQL: `SELECT
|
|
||||||
day_of_week,
|
|
||||||
start_time,
|
|
||||||
end_time,
|
|
||||||
COUNT(*) as volunteers_available
|
|
||||||
FROM volunteer_availability av
|
|
||||||
WHERE av.created_at BETWEEN ?1 AND ?2
|
|
||||||
GROUP BY day_of_week, start_time, end_time
|
|
||||||
HAVING COUNT(*) <= 2
|
|
||||||
ORDER BY volunteers_available ASC, day_of_week, start_time`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "schedule_conflicts",
|
|
||||||
Name: "Schedule Conflicts",
|
|
||||||
Description: "Appointments scheduled outside volunteer availability",
|
|
||||||
SQL: `SELECT
|
SQL: `SELECT
|
||||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||||
a.appointment_date,
|
COUNT(DISTINCT av.availability_id) as availability_slots,
|
||||||
a.appointment_time,
|
SUM(EXTRACT(EPOCH FROM (av.end_time - av.start_time))/3600) as total_hours_available,
|
||||||
ad.address,
|
COUNT(DISTINCT a.sched_id) as appointments_scheduled,
|
||||||
'No availability recorded' as conflict_reason
|
COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE THEN a.sched_id END) as past_appointments,
|
||||||
FROM appointment a
|
COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
|
||||||
JOIN users u ON a.user_id = u.user_id
|
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
||||||
JOIN address_database ad ON a.address_id = ad.address_id
|
AND DATE(p.created_at) = a.appointment_date
|
||||||
LEFT JOIN volunteer_availability av ON u.user_id = av.user_id
|
) THEN a.sched_id END) as completed_appointments,
|
||||||
AND EXTRACT(dow FROM a.appointment_date) = av.day_of_week
|
CASE
|
||||||
AND a.appointment_time BETWEEN av.start_time AND av.end_time
|
WHEN COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE THEN a.sched_id END) > 0
|
||||||
WHERE a.appointment_date BETWEEN ?1 AND ?2
|
THEN COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
|
||||||
AND av.user_id IS NULL
|
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
||||||
ORDER BY a.appointment_date, a.appointment_time`,
|
AND DATE(p.created_at) = a.appointment_date
|
||||||
|
) THEN a.sched_id END) * 100.0 / COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE THEN a.sched_id END)
|
||||||
|
ELSE 0
|
||||||
|
END as fulfillment_rate_percent
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN availability av ON u.user_id = av.user_id AND av.created_at BETWEEN ?1 AND ?2
|
||||||
|
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
|
||||||
|
WHERE u.role_id IN (2, 3)
|
||||||
|
GROUP BY u.user_id, u.first_name, u.last_name
|
||||||
|
HAVING COUNT(DISTINCT av.availability_id) > 0 OR COUNT(DISTINCT a.sched_id) > 0
|
||||||
|
ORDER BY fulfillment_rate_percent DESC, total_hours_available DESC`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,33 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"time"
|
||||||
|
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func VolunteerSchedualHandler(w *http.ResponseWriter, r http.Request) {
|
func ValidatedFreeTime(parsedDate time.Time, assignTime time.Time, userID int) (bool) {
|
||||||
|
var startTime, endTime time.Time
|
||||||
|
|
||||||
fmt.Print("Not Implementated Yet!!!")
|
dateOnly := parsedDate.Format("2006-01-02")
|
||||||
|
|
||||||
|
err := models.DB.QueryRow(
|
||||||
|
`SELECT start_time, end_time
|
||||||
|
FROM availability
|
||||||
|
WHERE user_id = $1 AND day = $2`,
|
||||||
|
userID, dateOnly,
|
||||||
|
).Scan(&startTime, &endTime)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Database query failed: %v\n", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if assignTime.After(startTime) && assignTime.Before(endTime) {
|
||||||
|
return true
|
||||||
|
}else{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@
|
|||||||
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full sm:w-auto">
|
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full sm:w-auto">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="px-6 py-2.5 bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors"
|
class="px-6 py-2.5 bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors rounded-lg"
|
||||||
onclick="window.location.href='/addresses/upload-csv'"
|
onclick="window.location.href='/addresses/upload-csv'"
|
||||||
>
|
>
|
||||||
<i class="fas fa-file-import mr-2"></i>Import Data
|
<i class="fas fa-file-import mr-2"></i>Import Data
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<option value="100" {{if eq .Pagination.PageSize 100}}selected{{end}}>100</option>
|
<option value="100" {{if eq .Pagination.PageSize 100}}selected{{end}}>100</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onclick="goToPage({{.Pagination.PreviousPage}})"
|
onclick="goToPage({{.Pagination.PreviousPage}})"
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
<!-- Table Container -->
|
<!-- Table Container -->
|
||||||
<div class="flex-1 p-4 md:p-6 overflow-auto">
|
<div class="flex-1 p-4 md:p-6 overflow-auto">
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
|
||||||
<!-- Desktop Table -->
|
<!-- Desktop Table -->
|
||||||
<div class="hidden lg:block overflow-x-auto">
|
<div class="hidden lg:block overflow-x-auto">
|
||||||
<table class="w-full min-w-full">
|
<table class="w-full min-w-full">
|
||||||
@@ -141,8 +141,9 @@
|
|||||||
<button
|
<button
|
||||||
class="px-3 py-1 bg-gray-100 text-gray-500 text-sm rounded-md cursor-not-allowed"
|
class="px-3 py-1 bg-gray-100 text-gray-500 text-sm rounded-md cursor-not-allowed"
|
||||||
disabled
|
disabled
|
||||||
|
hidden
|
||||||
>
|
>
|
||||||
Assigned
|
<i class="fa-solid fa-plus text-orange-400"></i>
|
||||||
</button>
|
</button>
|
||||||
<form action="/remove_assigned_address" method="POST" class="inline-block">
|
<form action="/remove_assigned_address" method="POST" class="inline-block">
|
||||||
<input type="hidden" name="address_id" value="{{ .AddressID }}" />
|
<input type="hidden" name="address_id" value="{{ .AddressID }}" />
|
||||||
@@ -151,16 +152,17 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
class="text-red-400 hover:text-red-600 p-1"
|
class="text-red-400 hover:text-red-600 p-1"
|
||||||
title="Remove assignment"
|
title="Remove assignment"
|
||||||
|
|
||||||
>
|
>
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<button
|
<button
|
||||||
class="px-3 py-1 bg-blue-500 text-white text-sm rounded-md hover:bg-blue-600 transition-colors"
|
class="px-3 py-1 bg-blue-100 text-white text-sm rounded-md hover:bg-blue-600 transition-colors"
|
||||||
onclick="openAssignModal({{ .AddressID }}, '{{ .Address }}')"
|
onclick="openAssignModal({{ .AddressID }}, '{{ .Address }}')"
|
||||||
>
|
>
|
||||||
Assign
|
<i class="fa-solid fa-plus text-blue-500"></i>
|
||||||
</button>
|
</button>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
#single-map {
|
#single-map {
|
||||||
height: 50vh; /* Smaller height on mobile */
|
height: 50vh; /* Smaller height on mobile */
|
||||||
}
|
}
|
||||||
|
|
||||||
body.sidebar-open .map-controls {
|
body.sidebar-open .map-controls {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -98,7 +97,6 @@
|
|||||||
.dashboard-container {
|
.dashboard-container {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
#single-map {
|
#single-map {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@@ -134,12 +132,6 @@
|
|||||||
<button class="control-button" onclick="refreshMap()" title="Refresh Map">
|
<button class="control-button" onclick="refreshMap()" title="Refresh Map">
|
||||||
<i class="fas fa-sync-alt"></i>
|
<i class="fas fa-sync-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="control-button" onclick="fitAllMarkers()" title="Fit All Markers">
|
|
||||||
<i class="fas fa-expand-arrows-alt"></i>
|
|
||||||
</button>
|
|
||||||
<button class="control-button" onclick="clearAllMarkers()" title="Clear All Markers">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="single-map"></div>
|
<div id="single-map"></div>
|
||||||
|
|
||||||
@@ -202,26 +194,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Global variables - only one set
|
|
||||||
let theMap = null;
|
let theMap = null;
|
||||||
let markerLayer = null;
|
let markerLayer = null;
|
||||||
let popup = null;
|
let popup = null;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
|
||||||
// Clean initialization
|
|
||||||
function initializeMap() {
|
function initializeMap() {
|
||||||
if (initialized || !window.ol) {
|
if (initialized || !window.ol) return;
|
||||||
console.log("Map already initialized or OpenLayers not ready");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Initializing single map...");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Calgary coordinates
|
|
||||||
const center = ol.proj.fromLonLat([-114.0719, 51.0447]);
|
const center = ol.proj.fromLonLat([-114.0719, 51.0447]);
|
||||||
|
|
||||||
// Create the ONE AND ONLY map
|
|
||||||
theMap = new ol.Map({
|
theMap = new ol.Map({
|
||||||
target: "single-map",
|
target: "single-map",
|
||||||
layers: [
|
layers: [
|
||||||
@@ -235,7 +218,6 @@
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create popup
|
|
||||||
popup = new ol.Overlay({
|
popup = new ol.Overlay({
|
||||||
element: document.getElementById("popup"),
|
element: document.getElementById("popup"),
|
||||||
positioning: "bottom-center",
|
positioning: "bottom-center",
|
||||||
@@ -244,13 +226,11 @@
|
|||||||
});
|
});
|
||||||
theMap.addOverlay(popup);
|
theMap.addOverlay(popup);
|
||||||
|
|
||||||
// Close popup handler
|
|
||||||
document.getElementById("popup-closer").onclick = function () {
|
document.getElementById("popup-closer").onclick = function () {
|
||||||
popup.setPosition(undefined);
|
popup.setPosition(undefined);
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create marker layer
|
|
||||||
markerLayer = new ol.layer.Vector({
|
markerLayer = new ol.layer.Vector({
|
||||||
source: new ol.source.Vector(),
|
source: new ol.source.Vector(),
|
||||||
style: new ol.style.Style({
|
style: new ol.style.Style({
|
||||||
@@ -264,26 +244,18 @@
|
|||||||
});
|
});
|
||||||
theMap.addLayer(markerLayer);
|
theMap.addLayer(markerLayer);
|
||||||
|
|
||||||
// Click handler
|
|
||||||
theMap.on("click", function (event) {
|
theMap.on("click", function (event) {
|
||||||
const feature = theMap.forEachFeatureAtPixel(
|
const feature = theMap.forEachFeatureAtPixel(event.pixel, f => f);
|
||||||
event.pixel,
|
|
||||||
function (feature) {
|
|
||||||
return feature;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (feature && feature.get("address_data")) {
|
if (feature && feature.get("address_data")) {
|
||||||
const data = feature.get("address_data");
|
const data = feature.get("address_data");
|
||||||
document.getElementById("popup-content").innerHTML = `
|
document.getElementById("popup-content").innerHTML = `
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<h4 class="font-semibold text-gray-900 mb-2">Address Details</h4>
|
<h4 class="font-semibold text-gray-900 mb-2">Address Details</h4>
|
||||||
<p><strong>Address:</strong> ${data.address}</p>
|
<p><strong>Address:</strong> ${data.address}</p>
|
||||||
<p><strong>House #:</strong> ${data.house_number}</p>
|
<p><strong>House #:</strong> ${data.house_number}</p>
|
||||||
<p><strong>Street:</strong> ${data.street_name} ${data.street_type}</p>
|
<p><strong>Street:</strong> ${data.street_name} ${data.street_type}</p>
|
||||||
<p><strong>ID:</strong> ${data.address_id}</p>
|
<p><strong>ID:</strong> ${data.address_id}</p>
|
||||||
</div>
|
</div>`;
|
||||||
`;
|
|
||||||
popup.setPosition(event.coordinate);
|
popup.setPosition(event.coordinate);
|
||||||
} else {
|
} else {
|
||||||
popup.setPosition(undefined);
|
popup.setPosition(undefined);
|
||||||
@@ -291,45 +263,32 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
console.log("Map initialized successfully");
|
|
||||||
|
|
||||||
// Load markers
|
|
||||||
setTimeout(loadMarkers, 500);
|
setTimeout(loadMarkers, 500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Map initialization error:", error);
|
console.error("Map initialization error:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load validated addresses
|
|
||||||
async function loadMarkers() {
|
async function loadMarkers() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/validated-addresses");
|
const response = await fetch("/api/validated-addresses");
|
||||||
const addresses = await response.json();
|
const addresses = await response.json();
|
||||||
|
|
||||||
console.log(`Loading ${addresses.length} addresses`);
|
|
||||||
document.getElementById("marker-count").textContent = `${addresses.length} on map`;
|
document.getElementById("marker-count").textContent = `${addresses.length} on map`;
|
||||||
|
|
||||||
// Clear existing markers
|
|
||||||
markerLayer.getSource().clear();
|
markerLayer.getSource().clear();
|
||||||
|
const features = addresses
|
||||||
// Add new markers
|
.filter(addr => addr.longitude && addr.latitude)
|
||||||
const features = [];
|
.map(addr => new ol.Feature({
|
||||||
addresses.forEach((addr) => {
|
geometry: new ol.geom.Point(ol.proj.fromLonLat([addr.longitude, addr.latitude])),
|
||||||
if (addr.longitude && addr.latitude) {
|
address_data: addr,
|
||||||
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);
|
markerLayer.getSource().addFeatures(features);
|
||||||
|
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
const extent = markerLayer.getSource().getExtent();
|
const extent = markerLayer.getSource().getExtent();
|
||||||
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] });
|
theMap.getView(extent, { padding: [20, 20, 20, 20] });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading markers:", error);
|
console.error("Error loading markers:", error);
|
||||||
@@ -337,45 +296,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Control functions
|
|
||||||
function refreshMap() {
|
function refreshMap() {
|
||||||
|
const view = theMap.getView();
|
||||||
|
const calgaryCenter = ol.proj.fromLonLat([-114.0719, 51.0447]); // Downtown Calgary
|
||||||
|
view.setCenter(calgaryCenter);
|
||||||
|
view.setZoom(11); // your default zoom leve
|
||||||
loadMarkers();
|
loadMarkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fitAllMarkers() {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
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);
|
setTimeout(initializeMap, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for sidebar state changes to manage map controls visibility
|
|
||||||
function handleSidebarToggle() {
|
function handleSidebarToggle() {
|
||||||
const sidebar = document.getElementById('sidebar');
|
const sidebar = document.getElementById('sidebar');
|
||||||
const body = document.body;
|
document.body.classList.toggle('sidebar-open', sidebar && sidebar.classList.contains('active'));
|
||||||
|
|
||||||
if (sidebar && sidebar.classList.contains('active')) {
|
|
||||||
body.classList.add('sidebar-open');
|
|
||||||
} else {
|
|
||||||
body.classList.remove('sidebar-open');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override the original toggleSidebar function to handle map controls
|
|
||||||
if (typeof window.toggleSidebar === 'function') {
|
if (typeof window.toggleSidebar === 'function') {
|
||||||
const originalToggleSidebar = window.toggleSidebar;
|
const originalToggleSidebar = window.toggleSidebar;
|
||||||
window.toggleSidebar = function() {
|
window.toggleSidebar = function() {
|
||||||
@@ -384,4 +321,4 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile sidebar overlay */
|
/* Mobile sidebar overlay */
|
||||||
.sidebar-overlay {
|
.sidebar-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
{{ if .IsAuthenticated }}
|
{{ if .IsAuthenticated }}
|
||||||
<!-- Authenticated User Interface -->
|
<!-- Authenticated User Interface -->
|
||||||
<div class="min-h-screen">
|
<div class="min-h-screen">
|
||||||
|
|
||||||
<!-- Mobile sidebar overlay -->
|
<!-- Mobile sidebar overlay -->
|
||||||
<div id="sidebar-overlay" class="sidebar-overlay" onclick="toggleSidebar()"></div>
|
<div id="sidebar-overlay" class="sidebar-overlay" onclick="toggleSidebar()"></div>
|
||||||
|
|
||||||
@@ -167,11 +167,6 @@
|
|||||||
</a>
|
</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<a href="/profile" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "profile"}}bg-blue-light text-blue-primary border-r-4 border-blue-primary pl-2 rounded-none{{else}}text-text-secondary hover:bg-gray-50 rounded-md{{end}} group">
|
|
||||||
<i class="fas fa-user-circle w-5 {{if eq .ActiveSection "profile"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
|
||||||
<span {{if eq .ActiveSection "profile"}}class="font-medium"{{end}}>Profile</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="/logout" class="flex items-center px-3 py-2.5 text-sm text-text-secondary hover:bg-gray-50 rounded-md group">
|
<a href="/logout" class="flex items-center px-3 py-2.5 text-sm text-text-secondary hover:bg-gray-50 rounded-md group">
|
||||||
<i class="fas fa-sign-out-alt w-5 text-gray-400 mr-3"></i>
|
<i class="fas fa-sign-out-alt w-5 text-gray-400 mr-3"></i>
|
||||||
<span>Logout</span>
|
<span>Logout</span>
|
||||||
@@ -183,7 +178,7 @@
|
|||||||
<!-- Main Content Container -->
|
<!-- Main Content Container -->
|
||||||
<div class="main-content-container min-h-screen flex flex-col bg-custom-gray">
|
<div class="main-content-container min-h-screen flex flex-col bg-custom-gray">
|
||||||
<!-- Top Header -->
|
<!-- Top Header -->
|
||||||
<div class="bg-white border-b border-border-gray px-4 md:px-6 py-4">
|
<div class="fixed top-0 left-0 right-0 z-20 bg-white border-b border-border-gray px-4 md:px-6 py-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<!-- Hamburger (left aligned with consistent spacing) -->
|
<!-- Hamburger (left aligned with consistent spacing) -->
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -195,9 +190,9 @@
|
|||||||
<!-- Right side -->
|
<!-- Right side -->
|
||||||
<div class="flex items-center space-x-2 md:space-x-4">
|
<div class="flex items-center space-x-2 md:space-x-4">
|
||||||
<!-- Dark mode -->
|
<!-- Dark mode -->
|
||||||
<button class="text-text-secondary hover:text-text-primary p-2">
|
<a href="/logout" class="text-text-secondary hover:text-text-primary p-2">
|
||||||
<i class="fas fa-moon text-lg"></i>
|
<i class="fa-solid fa-arrow-right-from-bracket text-lg"></i>
|
||||||
</button>
|
</a>
|
||||||
|
|
||||||
<!-- Profile (hover dropdown on desktop, click on mobile) -->
|
<!-- Profile (hover dropdown on desktop, click on mobile) -->
|
||||||
<div class="relative group cursor-pointer">
|
<div class="relative group cursor-pointer">
|
||||||
@@ -211,7 +206,7 @@
|
|||||||
<!-- Dropdown -->
|
<!-- Dropdown -->
|
||||||
<div id="profile-menu" class="absolute right-0 mt-2 w-40 bg-white border border-border-gray rounded-md shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
<div id="profile-menu" class="absolute right-0 mt-2 w-40 bg-white border border-border-gray rounded-md shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
||||||
<a href="/profile" class="block px-4 py-2 text-sm text-text-primary hover:bg-gray-100">Profile</a>
|
<a href="/profile" class="block px-4 py-2 text-sm text-text-primary hover:bg-gray-100">Profile</a>
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-text-primary hover:bg-gray-100">Settings</a>
|
<a href="/profile" class="block px-4 py-2 text-sm text-text-primary hover:bg-gray-100">Settings</a>
|
||||||
<a href="/logout" class="block px-4 py-2 text-sm text-red-600 hover:bg-gray-100">Logout</a>
|
<a href="/logout" class="block px-4 py-2 text-sm text-red-600 hover:bg-gray-100">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,7 +215,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<div class="flex-1">
|
<div class="flex-1 mt-20">
|
||||||
{{ template "content" . }}
|
{{ template "content" . }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,15 +224,15 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<!-- Split Screen Login/Register Page -->
|
<!-- Split Screen Login/Register Page -->
|
||||||
<div class="min-h-screen flex" x-data="{ isLogin: true }">
|
<div class="min-h-screen flex" x-data="{ isLogin: true }">
|
||||||
|
|
||||||
<!-- Left Side - Image -->
|
<!-- Left Side - Image -->
|
||||||
<div class="hidden lg:flex flex-1 relative overflow-hidden">
|
<div class="hidden lg:flex flex-1 relative overflow-hidden">
|
||||||
<!-- Background overlay for better text readability -->
|
<!-- Background overlay for better text readability -->
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-blue-700/40 z-10"></div>
|
<div class="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-blue-700/40 z-10"></div>
|
||||||
|
|
||||||
<!-- Background Image -->
|
<!-- Background Image -->
|
||||||
<img src="../../static/feature-mobile1.jpg" alt="Welcome to Poll System" class="object-cover w-full h-full"/>
|
<img src="../../static/feature-mobile1.jpg" alt="Welcome to Poll System" class="object-cover w-full h-full"/>
|
||||||
|
|
||||||
<!-- Logo and branding overlay -->
|
<!-- Logo and branding overlay -->
|
||||||
<div class="absolute top-8 left-8 z-20 flex items-center gap-3">
|
<div class="absolute top-8 left-8 z-20 flex items-center gap-3">
|
||||||
<div class="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
|
<div class="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
|
||||||
@@ -245,7 +240,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="text-2xl font-bold text-white">Linq</span>
|
<span class="text-2xl font-bold text-white">Linq</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Welcome text overlay -->
|
<!-- Welcome text overlay -->
|
||||||
<div class="absolute bottom-8 left-8 right-8 z-20 text-white">
|
<div class="absolute bottom-8 left-8 right-8 z-20 text-white">
|
||||||
<h1 class="text-4xl font-bold mb-4">Welcome to Poll System</h1>
|
<h1 class="text-4xl font-bold mb-4">Welcome to Poll System</h1>
|
||||||
@@ -256,7 +251,7 @@
|
|||||||
<!-- Right Side - Login/Register Forms -->
|
<!-- Right Side - Login/Register Forms -->
|
||||||
<div class="flex-1 flex items-center justify-center p-6 lg:p-12 bg-white">
|
<div class="flex-1 flex items-center justify-center p-6 lg:p-12 bg-white">
|
||||||
<div class="w-full max-w-md">
|
<div class="w-full max-w-md">
|
||||||
|
|
||||||
<!-- Mobile Logo (visible only on small screens) -->
|
<!-- Mobile Logo (visible only on small screens) -->
|
||||||
<div class="lg:hidden flex items-center justify-center gap-3 mb-8">
|
<div class="lg:hidden flex items-center justify-center gap-3 mb-8">
|
||||||
<div class="w-10 h-10 bg-blue-primary rounded-full flex items-center justify-center">
|
<div class="w-10 h-10 bg-blue-primary rounded-full flex items-center justify-center">
|
||||||
@@ -282,27 +277,27 @@
|
|||||||
<h2 class="text-3xl font-bold text-text-primary">Welcome back</h2>
|
<h2 class="text-3xl font-bold text-text-primary">Welcome back</h2>
|
||||||
<p class="text-text-secondary mt-2">Please sign in to your account</p>
|
<p class="text-text-secondary mt-2">Please sign in to your account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="login_email" class="block text-sm font-medium text-text-primary mb-2">Email Address</label>
|
<label for="login_email" class="block text-sm font-medium text-text-primary mb-2">Email Address</label>
|
||||||
<input type="email"
|
<input type="email"
|
||||||
id="login_email"
|
id="login_email"
|
||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="login_password" class="block text-sm font-medium text-text-primary mb-2">Password</label>
|
<label for="login_password" class="block text-sm font-medium text-text-primary mb-2">Password</label>
|
||||||
<input type="password"
|
<input type="password"
|
||||||
id="login_password"
|
id="login_password"
|
||||||
name="password"
|
name="password"
|
||||||
required
|
required
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox" class="h-4 w-4 text-blue-primary focus:ring-blue-primary border-gray-300 rounded">
|
<input type="checkbox" class="h-4 w-4 text-blue-primary focus:ring-blue-primary border-gray-300 rounded">
|
||||||
@@ -310,7 +305,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<a href="#" class="text-sm text-blue-primary hover:text-blue-600">Forgot password?</a>
|
<a href="#" class="text-sm text-blue-primary hover:text-blue-600">Forgot password?</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="w-full bg-blue-primary text-white py-3 rounded-lg hover:bg-blue-600 focus:ring-2 focus:ring-blue-primary focus:ring-offset-2 transition-colors font-medium">
|
<button type="submit" class="w-full bg-blue-primary text-white py-3 rounded-lg hover:bg-blue-600 focus:ring-2 focus:ring-blue-primary focus:ring-offset-2 transition-colors font-medium">
|
||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
@@ -324,49 +319,49 @@
|
|||||||
<h2 class="text-3xl font-bold text-text-primary">Create Account</h2>
|
<h2 class="text-3xl font-bold text-text-primary">Create Account</h2>
|
||||||
<p class="text-text-secondary mt-2">Join our polling platform today</p>
|
<p class="text-text-secondary mt-2">Join our polling platform today</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-text-primary mb-2">First Name</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">First Name</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
name="first_name"
|
name="first_name"
|
||||||
required
|
required
|
||||||
placeholder="First Name"
|
placeholder="First Name"
|
||||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-text-primary mb-2">Last Name</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">Last Name</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
name="last_name"
|
name="last_name"
|
||||||
required
|
required
|
||||||
placeholder="Last Name"
|
placeholder="Last Name"
|
||||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-text-primary mb-2">Email Address</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">Email Address</label>
|
||||||
<input type="email"
|
<input type="email"
|
||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-text-primary mb-2">Phone</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">Phone</label>
|
||||||
<input type="tel"
|
<input type="tel"
|
||||||
name="phone"
|
name="phone"
|
||||||
required
|
required
|
||||||
placeholder="Phone number"
|
placeholder="Phone number"
|
||||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-text-primary mb-2">Role</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">Role</label>
|
||||||
<select name="role"
|
<select name="role"
|
||||||
required
|
required
|
||||||
onchange="toggleAdminCodeField()"
|
onchange="toggleAdminCodeField()"
|
||||||
id="role"
|
id="role"
|
||||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors">
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors">
|
||||||
<option value="">Select Role</option>
|
<option value="">Select Role</option>
|
||||||
@@ -374,26 +369,26 @@
|
|||||||
<option value="3">Volunteer</option>
|
<option value="3">Volunteer</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Admin/Team Leader Code Field (hidden by default) -->
|
<!-- Admin/Team Leader Code Field (hidden by default) -->
|
||||||
<div id="adminCodeField" class="hidden">
|
<div id="adminCodeField" class="hidden">
|
||||||
<label class="block text-sm font-medium text-text-primary mb-2">Access Code</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">Access Code</label>
|
||||||
<input type="password"
|
<input type="password"
|
||||||
name="admin_code"
|
name="admin_code"
|
||||||
placeholder="Enter access code"
|
placeholder="Enter access code"
|
||||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
<p class="text-xs text-text-secondary mt-1">Required for Admin and Team Leader roles</p>
|
<p class="text-xs text-text-secondary mt-1">Required for Admin and Team Leader roles</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-text-primary mb-2">Password</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">Password</label>
|
||||||
<input type="password"
|
<input type="password"
|
||||||
name="password"
|
name="password"
|
||||||
required
|
required
|
||||||
placeholder="Create a password"
|
placeholder="Create a password"
|
||||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="w-full bg-blue-primary text-white py-3 rounded-lg hover:bg-blue-600 focus:ring-2 focus:ring-blue-primary focus:ring-offset-2 transition-colors font-medium">
|
<button type="submit" class="w-full bg-blue-primary text-white py-3 rounded-lg hover:bg-blue-600 focus:ring-2 focus:ring-blue-primary focus:ring-offset-2 transition-colors font-medium">
|
||||||
Create Account
|
Create Account
|
||||||
</button>
|
</button>
|
||||||
@@ -410,21 +405,42 @@
|
|||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
const sidebar = document.getElementById('sidebar');
|
const sidebar = document.getElementById('sidebar');
|
||||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||||
|
|
||||||
if (sidebar && sidebarOverlay) {
|
if (sidebar && sidebarOverlay) {
|
||||||
sidebar.classList.toggle('active');
|
sidebar.classList.toggle('active');
|
||||||
sidebarOverlay.classList.toggle('active');
|
sidebarOverlay.classList.toggle('active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile menu toggle
|
|
||||||
function toggleProfileMenu() {
|
function toggleProfileMenu() {
|
||||||
const menu = document.getElementById('profile-menu');
|
const menu = document.getElementById('profile-menu');
|
||||||
if (menu) {
|
if (menu) {
|
||||||
menu.classList.toggle('opacity-0');
|
const isVisible = !menu.classList.contains('invisible');
|
||||||
menu.classList.toggle('invisible');
|
if (isVisible) {
|
||||||
menu.classList.toggle('opacity-100');
|
// Hide it
|
||||||
menu.classList.toggle('visible');
|
menu.classList.add('opacity-0', 'invisible');
|
||||||
|
menu.classList.remove('opacity-100', 'visible');
|
||||||
|
document.removeEventListener('click', outsideClickListener);
|
||||||
|
} else {
|
||||||
|
// Show it
|
||||||
|
menu.classList.remove('opacity-0', 'invisible');
|
||||||
|
menu.classList.add('opacity-100', 'visible');
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', outsideClickListener);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function outsideClickListener(event) {
|
||||||
|
const menu = document.getElementById('profile-menu');
|
||||||
|
const trigger = event.target.closest('.group'); // The profile button wrapper
|
||||||
|
|
||||||
|
if (menu && !menu.contains(event.target) && !trigger) {
|
||||||
|
// Hide menu
|
||||||
|
menu.classList.add('opacity-0', 'invisible');
|
||||||
|
menu.classList.remove('opacity-100', 'visible');
|
||||||
|
document.removeEventListener('click', outsideClickListener);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +448,7 @@
|
|||||||
function toggleAdminCodeField() {
|
function toggleAdminCodeField() {
|
||||||
const role = document.getElementById("role");
|
const role = document.getElementById("role");
|
||||||
const field = document.getElementById("adminCodeField");
|
const field = document.getElementById("adminCodeField");
|
||||||
|
|
||||||
if (role && field) {
|
if (role && field) {
|
||||||
const roleValue = role.value;
|
const roleValue = role.value;
|
||||||
if (roleValue === "1") { // Admin or Team Leader
|
if (roleValue === "1") { // Admin or Team Leader
|
||||||
@@ -449,7 +465,7 @@
|
|||||||
const sidebar = document.getElementById('sidebar');
|
const sidebar = document.getElementById('sidebar');
|
||||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
|
|
||||||
if (sidebar && sidebarOverlay && sidebar.classList.contains('active')) {
|
if (sidebar && sidebarOverlay && sidebar.classList.contains('active')) {
|
||||||
sidebar.classList.remove('active');
|
sidebar.classList.remove('active');
|
||||||
sidebarOverlay.classList.remove('active');
|
sidebarOverlay.classList.remove('active');
|
||||||
@@ -464,7 +480,7 @@
|
|||||||
const sidebar = document.getElementById('sidebar');
|
const sidebar = document.getElementById('sidebar');
|
||||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
|
|
||||||
if (sidebar && sidebarOverlay) {
|
if (sidebar && sidebarOverlay) {
|
||||||
sidebar.classList.remove('active');
|
sidebar.classList.remove('active');
|
||||||
sidebarOverlay.classList.remove('active');
|
sidebarOverlay.classList.remove('active');
|
||||||
@@ -485,4 +501,4 @@
|
|||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
>
|
>
|
||||||
<option value="">Select Category</option>
|
<option value="">Select Category</option>
|
||||||
<option value="users" {{if eq .Category "users"}}selected{{end}}>Users & Teams</option>
|
<option value="users" {{if eq .Category "users"}}selected{{end}}>Users & Teams</option>
|
||||||
<option value="address" {{if eq .Category "address"}}selected{{end}}>Addresses</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="appointments" {{if eq .Category "appointments"}}selected{{end}}>Appointments</option>
|
||||||
<option value="polls" {{if eq .Category "polls"}}selected{{end}}>Polls</option>
|
<option value="polls" {{if eq .Category "polls"}}selected{{end}}>Polls</option>
|
||||||
<option value="availability" {{if eq .Category "availability"}}selected{{end}}>Availability</option>
|
<option value="availability" {{if eq .Category "availability"}}selected{{end}}>Availability</option>
|
||||||
@@ -53,19 +53,19 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
{{if .Result}}
|
{{if .Result}}
|
||||||
<div class="flex items-center gap-3 text-sm">
|
<div class="flex items-center gap-3 text-sm">
|
||||||
<div class="text-gray-600">
|
<div class="text-gray-600">
|
||||||
<span>{{.Result.Count}} results</span>
|
<span>{{.Result.Count}} results</span>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="exportResults()" class="px-3 py-1.5 bg-green-600 text-white hover:bg-green-700 transition-colors">
|
<!-- <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
|
<i class="fas fa-download mr-1"></i>Export CSV
|
||||||
</button>
|
</button>
|
||||||
<button onclick="printReport()" class="px-3 py-1.5 bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
<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
|
<i class="fas fa-print mr-1"></i>Print
|
||||||
</button>
|
</button> -->
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -146,34 +146,32 @@
|
|||||||
<script>
|
<script>
|
||||||
const reportDefinitions = {
|
const reportDefinitions = {
|
||||||
users: [
|
users: [
|
||||||
{ id: 'participation', name: 'Volunteer Participation Rate' },
|
{ id: 'volunteer_participation_rate', name: 'Volunteer Participation Rate' },
|
||||||
{ id: 'top_performers', name: 'Top Performing Volunteers' },
|
{ id: 'top_performing_volunteers', name: 'Top-Performing Volunteers & Team Leads' },
|
||||||
{ id: 'efficiency', name: 'Response-to-Donation Ratio' },
|
{ id: 'response_donation_ratio', name: 'Response-to-Donation Ratio per Volunteer' },
|
||||||
{ id: 'coverage', name: 'User Address Coverage' }
|
{ id: 'user_address_coverage', name: 'User Address Coverage' }
|
||||||
],
|
],
|
||||||
address: [
|
addresses: [
|
||||||
{ id: 'responses_by_address', name: 'Total Responses by Address' },
|
{ id: 'poll_responses_by_address', name: 'Total Poll Responses by Address' },
|
||||||
{ id: 'donations_by_address', name: 'Total Donations by Address' },
|
{ id: 'donations_by_address', name: 'Total Donations by Address' },
|
||||||
{ id: 'street_breakdown', name: 'Street-Level Breakdown' },
|
{ id: 'street_level_breakdown', name: 'Street-Level Breakdown (Responses & Donations)' },
|
||||||
{ id: 'quadrant_summary', name: 'Quadrant Summary' }
|
{ id: 'quadrant_summary', name: 'Quadrant-Level Summary (NE, NW, SE, SW)' }
|
||||||
],
|
],
|
||||||
appointments: [
|
appointments: [
|
||||||
{ id: 'upcoming', name: 'Upcoming Appointments' },
|
{ id: 'upcoming_appointments', name: 'Upcoming Appointments per Volunteer/Team Lead' },
|
||||||
{ id: 'completion', name: 'Appointments Completion Rate' },
|
{ id: 'missed_vs_completed', name: 'Missed vs Completed Appointments' },
|
||||||
{ id: 'geo_distribution', name: 'Appointments by Quadrant' },
|
{ id: 'appointments_by_quadrant', name: 'Appointments by Quadrant/Region' },
|
||||||
{ id: 'lead_time', name: 'Average Lead Time' }
|
{ id: 'scheduling_lead_time', name: 'Average Lead Time (Scheduled vs Actual Date)' }
|
||||||
],
|
],
|
||||||
polls: [
|
polls: [
|
||||||
{ id: 'distribution', name: 'Response Distribution' },
|
{ id: 'response_distribution', name: 'Response Distribution (Yes/No/Neutral)' },
|
||||||
{ id: 'average', name: 'Average Poll Response' },
|
{ id: 'average_poll_response', name: 'Average Poll Response (Yes/No %)' },
|
||||||
{ id: 'donations_by_poll', name: 'Donations by Poll' },
|
{ id: 'donations_by_poll', name: 'Donations by Poll' },
|
||||||
{ id: 'correlation', name: 'Response-to-Donation Correlation' }
|
{ id: 'response_donation_correlation', name: 'Response-to-Donation Correlation' }
|
||||||
],
|
],
|
||||||
availability: [
|
availability: [
|
||||||
{ id: 'by_date', name: 'Volunteer Availability by Date' },
|
{ id: 'volunteer_availability_schedule', name: 'Volunteer Availability by Date Range' },
|
||||||
{ id: 'gaps', name: 'Coverage Gaps' },
|
{ id: 'volunteer_fulfillment', name: 'Volunteer Fulfillment (Available vs Actually Worked)' }
|
||||||
{ id: 'overlaps', name: 'Volunteer Overlaps' },
|
|
||||||
{ id: 'fulfillment', name: 'Volunteer Fulfillment' }
|
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ func main() {
|
|||||||
|
|
||||||
http.HandleFunc("/addresses/upload-csv", adminMiddleware(handlers.CSVUploadHandler))
|
http.HandleFunc("/addresses/upload-csv", adminMiddleware(handlers.CSVUploadHandler))
|
||||||
http.HandleFunc("/reports", adminMiddleware(handlers.ReportsHandler))
|
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", handlers.GetValidatedAddressesHandler)
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
exit status 1
|
|
||||||
Reference in New Issue
Block a user