feat: validate user availability
This commit is contained in:
16
README.MD
16
README.MD
@@ -1,4 +1,16 @@
|
||||
# Poll-system
|
||||
|
||||
- TODO: Reports Generation, Export csv, Print Pdf, Show Charts
|
||||
- TODO: VOlunteer Schedual or avilablity
|
||||
- TODO: volunteer Available
|
||||
- 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"
|
||||
"strconv"
|
||||
"time"
|
||||
"fmt"
|
||||
|
||||
"github.com/patel-mann/poll-system/app/internal/models"
|
||||
"github.com/patel-mann/poll-system/app/internal/utils"
|
||||
@@ -303,10 +304,13 @@ func AssignAddressHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Parse and validate the start time
|
||||
_, err = time.Parse("15:04", startTime)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid start time format", http.StatusBadRequest)
|
||||
parsedTime, err := time.Parse("15:04", startTime)
|
||||
is_valid := ValidatedFreeTime(parsedDate, parsedTime, userID)
|
||||
if is_valid != true {
|
||||
http.Error(w, "User is not availabile", http.StatusBadRequest)
|
||||
return
|
||||
}else{
|
||||
fmt.Print("hello")
|
||||
}
|
||||
|
||||
// Verify the user exists and is associated with the current admin
|
||||
|
||||
@@ -177,577 +177,425 @@ func getAllReportDefinitions() map[string][]ReportDefinition {
|
||||
return map[string][]ReportDefinition{
|
||||
"users": {
|
||||
{
|
||||
ID: "volunteer_participation_rate", // get all the appointment(done, notdone, total) poll(done, not doen, total)
|
||||
Name: "Volunteer participation rate",
|
||||
Description: "Count of users grouped by their role",
|
||||
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",
|
||||
ID: "volunteer_participation_rate",
|
||||
Name: "Volunteer Participation Rate",
|
||||
Description: "Poll responses and donations collected by each volunteer/team lead",
|
||||
SQL: `SELECT
|
||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||
u.email,
|
||||
COUNT(DISTINCT a.sched_id) as appointments_count,
|
||||
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,
|
||||
r.name as role,
|
||||
COUNT(DISTINCT p.poll_id) as polls_created,
|
||||
COUNT(DISTINCT pr.poll_response_id) as responses_collected,
|
||||
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||
COUNT(DISTINCT a.sched_id) as appointments_scheduled
|
||||
FROM users u
|
||||
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_response pr ON p.poll_id = pr.poll_id
|
||||
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
|
||||
WHERE u.role_id = 1
|
||||
GROUP BY u.user_id, u.first_name, u.last_name, u.email
|
||||
ORDER BY teams_managed DESC, polls_created DESC`,
|
||||
WHERE u.role_id IN (2, 3)
|
||||
GROUP BY u.user_id, u.first_name, u.last_name, u.email, r.name
|
||||
ORDER BY responses_collected DESC, total_donations DESC`,
|
||||
},
|
||||
{
|
||||
ID: "inactive_users",
|
||||
Name: "Inactive Users Report",
|
||||
Description: "Users with no recent activity",
|
||||
ID: "top_performing_volunteers",
|
||||
Name: "Top-Performing Volunteers & Team Leads",
|
||||
Description: "Volunteers ranked by responses collected and donations secured",
|
||||
SQL: `SELECT
|
||||
u.first_name || ' ' || u.last_name as user_name,
|
||||
u.email,
|
||||
CASE
|
||||
WHEN u.role_id = 1 THEN 'Admin'
|
||||
WHEN u.role_id = 2 THEN 'Volunteer'
|
||||
ELSE 'Unknown'
|
||||
END as role,
|
||||
u.created_at as joined_date,
|
||||
COALESCE(MAX(a.created_at), MAX(p.created_at)) as last_activity
|
||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||
r.name as role,
|
||||
COUNT(DISTINCT pr.poll_response_id) as responses_collected,
|
||||
COALESCE(SUM(pr.question6_donation_amount), 0) as donations_secured,
|
||||
COUNT(DISTINCT p.poll_id) as polls_created,
|
||||
AVG(pr.question6_donation_amount) as avg_donation_per_poll
|
||||
FROM users u
|
||||
LEFT JOIN appointment a ON u.user_id = a.user_id
|
||||
LEFT 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
|
||||
HAVING COALESCE(MAX(a.created_at), MAX(p.created_at)) < ?1 OR COALESCE(MAX(a.created_at), MAX(p.created_at)) IS NULL
|
||||
ORDER BY last_activity DESC`,
|
||||
JOIN role r ON u.role_id = r.role_id
|
||||
JOIN poll p ON u.user_id = p.user_id
|
||||
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||
WHERE u.role_id IN (2, 3) AND p.created_at BETWEEN ?1 AND ?2
|
||||
GROUP BY u.user_id, u.first_name, u.last_name, r.name
|
||||
HAVING COUNT(DISTINCT pr.poll_response_id) > 0
|
||||
ORDER BY responses_collected DESC, donations_secured DESC
|
||||
LIMIT 20`,
|
||||
},
|
||||
{
|
||||
ID: "response_donation_ratio",
|
||||
Name: "Response-to-Donation Ratio per Volunteer",
|
||||
Description: "Efficiency measure showing donation amount per response collected",
|
||||
SQL: `SELECT
|
||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
||||
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT pr.poll_response_id) > 0
|
||||
THEN COALESCE(SUM(pr.question6_donation_amount), 0) / COUNT(DISTINCT pr.poll_response_id)
|
||||
ELSE 0
|
||||
END as donation_per_response,
|
||||
CASE
|
||||
WHEN COALESCE(SUM(pr.question6_donation_amount), 0) > 0
|
||||
THEN COUNT(DISTINCT pr.poll_response_id) / COALESCE(SUM(pr.question6_donation_amount), 1)
|
||||
ELSE COUNT(DISTINCT pr.poll_response_id)
|
||||
END as responses_per_dollar
|
||||
FROM users u
|
||||
JOIN poll p ON u.user_id = p.user_id
|
||||
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||
WHERE u.role_id IN (2, 3) AND p.created_at BETWEEN ?1 AND ?2
|
||||
GROUP BY u.user_id, u.first_name, u.last_name
|
||||
HAVING COUNT(DISTINCT pr.poll_response_id) > 0
|
||||
ORDER BY donation_per_response DESC`,
|
||||
},
|
||||
{
|
||||
ID: "user_address_coverage",
|
||||
Name: "User Address Coverage",
|
||||
Description: "Number of unique addresses covered by each volunteer/team lead",
|
||||
SQL: `SELECT
|
||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||
COUNT(DISTINCT p.address_id) as unique_addresses_polled,
|
||||
COUNT(DISTINCT a.address_id) as unique_addresses_appointed,
|
||||
COUNT(DISTINCT COALESCE(p.address_id, a.address_id)) as total_unique_addresses,
|
||||
COUNT(DISTINCT pr.poll_response_id) as total_responses
|
||||
FROM users u
|
||||
LEFT JOIN poll p ON u.user_id = p.user_id AND p.created_at BETWEEN ?1 AND ?2
|
||||
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
|
||||
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||
WHERE u.role_id IN (2, 3)
|
||||
GROUP BY u.user_id, u.first_name, u.last_name
|
||||
HAVING COUNT(DISTINCT COALESCE(p.address_id, a.address_id)) > 0
|
||||
ORDER BY total_unique_addresses DESC, total_responses DESC`,
|
||||
},
|
||||
},
|
||||
"addresses": {
|
||||
{
|
||||
ID: "coverage_by_area",
|
||||
Name: "Coverage by Area",
|
||||
Description: "Address coverage statistics by geographical area",
|
||||
ID: "poll_responses_by_address",
|
||||
Name: "Total Poll Responses by Address",
|
||||
Description: "Shows engagement hotspots - addresses with most poll responses",
|
||||
SQL: `SELECT
|
||||
COALESCE(NULLIF(TRIM(SPLIT_PART(address, ',', -1)), ''), 'Unknown') as area,
|
||||
COUNT(*) as total_addresses,
|
||||
COUNT(CASE WHEN visited_validated = true THEN 1 END) as visited_count,
|
||||
ROUND(COUNT(CASE WHEN visited_validated = true THEN 1 END) * 100.0 / COUNT(*), 2) as coverage_percentage
|
||||
FROM address_database
|
||||
WHERE created_at BETWEEN ?1 AND ?2
|
||||
GROUP BY area
|
||||
ORDER BY total_addresses DESC`,
|
||||
},
|
||||
{
|
||||
ID: "visits_by_postal",
|
||||
Name: "Visits by Postal Code",
|
||||
Description: "Visit statistics grouped by postal code",
|
||||
SQL: `SELECT
|
||||
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,
|
||||
COUNT(*) as addresses,
|
||||
COUNT(CASE WHEN visited_validated = true THEN 1 END) as visited,
|
||||
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
|
||||
ad.address,
|
||||
ad.postal_code,
|
||||
ad.street_quadrant,
|
||||
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
||||
COUNT(DISTINCT p.poll_id) as polls_conducted,
|
||||
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT pr.poll_response_id) > 0
|
||||
THEN COALESCE(SUM(pr.question6_donation_amount), 0) / COUNT(DISTINCT pr.poll_response_id)
|
||||
ELSE 0
|
||||
END as avg_donation_per_response
|
||||
FROM address_database ad
|
||||
JOIN poll p ON ad.address_id = p.address_id
|
||||
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||
WHERE p.created_at BETWEEN ?1 AND ?2
|
||||
GROUP BY ad.address_id, ad.address, ad.postal_code, ad.street_quadrant
|
||||
ORDER BY total_responses DESC, total_donations DESC
|
||||
LIMIT 50`,
|
||||
},
|
||||
{
|
||||
ID: "unvisited_addresses",
|
||||
Name: "Unvisited Addresses",
|
||||
Description: "List of addresses that haven't been visited",
|
||||
ID: "donations_by_address",
|
||||
Name: "Total Donations by Address",
|
||||
Description: "Shows financially supportive areas - addresses with highest donations",
|
||||
SQL: `SELECT
|
||||
address_id,
|
||||
address,
|
||||
latitude,
|
||||
longitude,
|
||||
created_at as added_date
|
||||
FROM address_database
|
||||
WHERE visited_validated = false
|
||||
AND created_at BETWEEN ?1 AND ?2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
},
|
||||
{
|
||||
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
|
||||
ad.address,
|
||||
ad.postal_code,
|
||||
ad.street_quadrant,
|
||||
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||
COUNT(DISTINCT p.poll_id) as polls_conducted,
|
||||
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
||||
AVG(pr.question6_donation_amount) as avg_donation_per_poll
|
||||
FROM address_database ad
|
||||
JOIN poll p ON ad.address_id = p.address_id
|
||||
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||
WHERE p.created_at BETWEEN ?1 AND ?2 AND pr.question6_donation_amount > 0
|
||||
GROUP BY ad.address_id, ad.address, ad.postal_code, ad.street_quadrant
|
||||
ORDER BY total_donations DESC
|
||||
LIMIT 50`,
|
||||
},
|
||||
{
|
||||
ID: "address_validation_status",
|
||||
Name: "Address Validation Status",
|
||||
Description: "Status of address validation across the database",
|
||||
ID: "street_level_breakdown",
|
||||
Name: "Street-Level Breakdown (Responses & Donations)",
|
||||
Description: "Granular view for targeting - responses and donations by street",
|
||||
SQL: `SELECT
|
||||
CASE
|
||||
WHEN visited_validated = true THEN 'Validated'
|
||||
WHEN visited_validated = false THEN 'Not Validated'
|
||||
ELSE 'Unknown'
|
||||
END as validation_status,
|
||||
COUNT(*) as address_count,
|
||||
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM address_database), 2) as percentage
|
||||
FROM address_database
|
||||
WHERE created_at BETWEEN ?1 AND ?2
|
||||
GROUP BY visited_validated
|
||||
ORDER BY address_count DESC`,
|
||||
ad.street_name,
|
||||
ad.street_type,
|
||||
ad.street_quadrant,
|
||||
COUNT(DISTINCT ad.address_id) as unique_addresses,
|
||||
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
||||
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||
COUNT(DISTINCT p.poll_id) as polls_conducted
|
||||
FROM address_database ad
|
||||
LEFT JOIN poll p ON ad.address_id = p.address_id AND p.created_at BETWEEN ?1 AND ?2
|
||||
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||
WHERE ad.street_name IS NOT NULL
|
||||
GROUP BY ad.street_name, ad.street_type, ad.street_quadrant
|
||||
HAVING COUNT(DISTINCT pr.poll_response_id) > 0 OR COALESCE(SUM(pr.question6_donation_amount), 0) > 0
|
||||
ORDER BY total_responses DESC, total_donations DESC`,
|
||||
},
|
||||
{
|
||||
ID: "quadrant_summary",
|
||||
Name: "Quadrant-Level Summary (NE, NW, SE, SW)",
|
||||
Description: "Higher-level trend view by city quadrants",
|
||||
SQL: `SELECT
|
||||
COALESCE(ad.street_quadrant, 'Unknown') as quadrant,
|
||||
COUNT(DISTINCT ad.address_id) as unique_addresses,
|
||||
COUNT(DISTINCT p.poll_id) as polls_conducted,
|
||||
COUNT(DISTINCT pr.poll_response_id) as total_responses,
|
||||
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||
AVG(pr.question6_donation_amount) as avg_donation_per_poll,
|
||||
COUNT(DISTINCT a.sched_id) as appointments_scheduled
|
||||
FROM address_database ad
|
||||
LEFT JOIN poll p ON ad.address_id = p.address_id AND p.created_at BETWEEN ?1 AND ?2
|
||||
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||
LEFT JOIN appointment a ON ad.address_id = a.address_id AND a.created_at BETWEEN ?1 AND ?2
|
||||
GROUP BY ad.street_quadrant
|
||||
ORDER BY total_responses DESC, total_donations DESC`,
|
||||
},
|
||||
},
|
||||
"appointments": {
|
||||
{
|
||||
ID: "appointments_by_day",
|
||||
Name: "Appointments by Day",
|
||||
Description: "Daily breakdown of appointment scheduling",
|
||||
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",
|
||||
ID: "upcoming_appointments",
|
||||
Name: "Upcoming Appointments per Volunteer/Team Lead",
|
||||
Description: "Scheduling load - upcoming appointments by user",
|
||||
SQL: `SELECT
|
||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||
COUNT(a.sched_id) as total_appointments,
|
||||
COUNT(CASE WHEN ad.visited_validated = true THEN 1 END) as completed_visits,
|
||||
ROUND(COUNT(CASE WHEN ad.visited_validated = true THEN 1 END) * 100.0 / COUNT(a.sched_id), 2) as completion_rate
|
||||
r.name as role,
|
||||
COUNT(*) as upcoming_appointments,
|
||||
MIN(a.appointment_date) as earliest_appointment,
|
||||
MAX(a.appointment_date) as latest_appointment,
|
||||
COUNT(CASE WHEN a.appointment_date = CURRENT_DATE THEN 1 END) as today_appointments,
|
||||
COUNT(CASE WHEN a.appointment_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '7 days' THEN 1 END) as week_appointments
|
||||
FROM appointment a
|
||||
JOIN users u ON a.user_id = u.user_id
|
||||
JOIN role r ON u.role_id = r.role_id
|
||||
WHERE a.appointment_date >= CURRENT_DATE AND a.appointment_date BETWEEN ?1 AND ?2
|
||||
GROUP BY u.user_id, u.first_name, u.last_name, r.name
|
||||
ORDER BY upcoming_appointments DESC`,
|
||||
},
|
||||
{
|
||||
ID: "missed_vs_completed",
|
||||
Name: "Missed vs Completed Appointments",
|
||||
Description: "Reliability metric - appointment completion rates",
|
||||
SQL: `SELECT
|
||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||
COUNT(*) as total_appointments,
|
||||
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END) as past_appointments,
|
||||
COUNT(CASE WHEN a.appointment_date >= CURRENT_DATE THEN 1 END) as upcoming_appointments,
|
||||
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
|
||||
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
||||
AND DATE(p.created_at) = a.appointment_date
|
||||
) THEN 1 END) as completed_appointments,
|
||||
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE AND NOT EXISTS (
|
||||
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
||||
AND DATE(p.created_at) = a.appointment_date
|
||||
) THEN 1 END) as missed_appointments,
|
||||
CASE
|
||||
WHEN COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END) > 0
|
||||
THEN COUNT(CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
|
||||
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
||||
AND DATE(p.created_at) = a.appointment_date
|
||||
) THEN 1 END) * 100.0 / COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END)
|
||||
ELSE 0
|
||||
END as completion_rate_percent
|
||||
FROM appointment a
|
||||
JOIN users u ON a.user_id = u.user_id
|
||||
LEFT JOIN address_database ad ON a.address_id = ad.address_id
|
||||
WHERE a.created_at BETWEEN ?1 AND ?2
|
||||
GROUP BY u.user_id, u.first_name, u.last_name
|
||||
HAVING COUNT(a.sched_id) > 0
|
||||
ORDER BY completion_rate DESC, total_appointments DESC`,
|
||||
ORDER BY completion_rate_percent DESC, total_appointments DESC`,
|
||||
},
|
||||
{
|
||||
ID: "volunteer_schedules",
|
||||
Name: "Volunteer Schedules",
|
||||
Description: "Current volunteer scheduling overview",
|
||||
ID: "appointments_by_quadrant",
|
||||
Name: "Appointments by Quadrant/Region",
|
||||
Description: "Geographic distribution of appointments",
|
||||
SQL: `SELECT
|
||||
COALESCE(ad.street_quadrant, 'Unknown') as quadrant,
|
||||
COUNT(*) as total_appointments,
|
||||
COUNT(CASE WHEN a.appointment_date >= CURRENT_DATE THEN 1 END) as upcoming,
|
||||
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END) as past,
|
||||
COUNT(DISTINCT a.user_id) as unique_volunteers,
|
||||
COUNT(DISTINCT a.address_id) as unique_addresses
|
||||
FROM appointment a
|
||||
JOIN address_database ad ON a.address_id = ad.address_id
|
||||
WHERE a.created_at BETWEEN ?1 AND ?2
|
||||
GROUP BY ad.street_quadrant
|
||||
ORDER BY total_appointments DESC`,
|
||||
},
|
||||
{
|
||||
ID: "scheduling_lead_time",
|
||||
Name: "Average Lead Time (Scheduled vs Actual Date)",
|
||||
Description: "Scheduling efficiency - time between scheduling and appointment",
|
||||
SQL: `SELECT
|
||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||
a.appointment_date,
|
||||
a.appointment_time,
|
||||
ad.address,
|
||||
a.created_at as scheduled_date
|
||||
COUNT(*) as total_appointments,
|
||||
AVG(a.appointment_date - DATE(a.created_at)) as avg_lead_time_days,
|
||||
MIN(a.appointment_date - DATE(a.created_at)) as min_lead_time_days,
|
||||
MAX(a.appointment_date - DATE(a.created_at)) as max_lead_time_days,
|
||||
COUNT(CASE WHEN a.appointment_date - DATE(a.created_at) < 1 THEN 1 END) as same_day_appointments,
|
||||
COUNT(CASE WHEN a.appointment_date - DATE(a.created_at) BETWEEN 1 AND 7 THEN 1 END) as week_ahead_appointments
|
||||
FROM appointment a
|
||||
JOIN users u ON a.user_id = u.user_id
|
||||
JOIN address_database ad ON a.address_id = ad.address_id
|
||||
WHERE a.appointment_date BETWEEN ?1 AND ?2
|
||||
ORDER BY a.appointment_date, a.appointment_time`,
|
||||
},
|
||||
{
|
||||
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`,
|
||||
WHERE a.created_at BETWEEN ?1 AND ?2
|
||||
GROUP BY u.user_id, u.first_name, u.last_name
|
||||
HAVING COUNT(*) > 0
|
||||
ORDER BY avg_lead_time_days ASC`,
|
||||
},
|
||||
},
|
||||
"polls": {
|
||||
{
|
||||
ID: "poll_creation_stats",
|
||||
Name: "Poll Creation Statistics",
|
||||
Description: "Overview of poll creation activity",
|
||||
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",
|
||||
ID: "response_distribution",
|
||||
Name: "Response Distribution (Yes/No/Neutral)",
|
||||
Description: "Outcome summary - distribution of poll responses",
|
||||
SQL: `SELECT
|
||||
CASE
|
||||
WHEN amount_donated = 0 THEN 'No Donation'
|
||||
WHEN amount_donated BETWEEN 0.01 AND 25 THEN '$1 - $25'
|
||||
WHEN amount_donated BETWEEN 25.01 AND 50 THEN '$26 - $50'
|
||||
WHEN amount_donated BETWEEN 50.01 AND 100 THEN '$51 - $100'
|
||||
ELSE 'Over $100'
|
||||
END as donation_range,
|
||||
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,
|
||||
WHEN question1_voted_before = true AND question2_vote_again = true THEN 'Previous Voter - Will Vote Again'
|
||||
WHEN question1_voted_before = true AND question2_vote_again = false THEN 'Previous Voter - Will Not Vote Again'
|
||||
WHEN question1_voted_before = false AND question2_vote_again = true THEN 'New Voter - Will Vote'
|
||||
WHEN question1_voted_before = false AND question2_vote_again = false THEN 'New Voter - Will Not Vote'
|
||||
WHEN question1_voted_before IS NULL OR question2_vote_again IS NULL THEN 'Incomplete Response'
|
||||
END as response_category,
|
||||
COUNT(*) as response_count,
|
||||
COUNT(CASE WHEN will_vote_again = true THEN 1 END) as will_vote_again_count,
|
||||
ROUND(COUNT(CASE WHEN will_vote_again = true THEN 1 END) * 100.0 / COUNT(*), 2) as vote_again_percentage
|
||||
COUNT(*) * 100.0 / (SELECT COUNT(*) FROM poll_response pr2
|
||||
JOIN poll p2 ON pr2.poll_id = p2.poll_id
|
||||
WHERE p2.created_at BETWEEN ?1 AND ?2) as percentage
|
||||
FROM poll_response pr
|
||||
JOIN poll p ON pr.poll_id = p.poll_id
|
||||
WHERE p.created_at BETWEEN ?1 AND ?2
|
||||
GROUP BY voter_before
|
||||
GROUP BY response_category
|
||||
ORDER BY response_count DESC`,
|
||||
},
|
||||
{
|
||||
ID: "sign_requests",
|
||||
Name: "Sign Requests Summary",
|
||||
Description: "Summary of lawn sign and banner requests",
|
||||
ID: "average_poll_response",
|
||||
Name: "Average Poll Response (Yes/No %)",
|
||||
Description: "Overall sentiment - percentage breakdown of responses",
|
||||
SQL: `SELECT
|
||||
'Lawn Signs' as sign_type,
|
||||
SUM(lawn_sign) as total_requested,
|
||||
SUM(CASE WHEN lawn_sign_status = 'delivered' THEN lawn_sign ELSE 0 END) as delivered,
|
||||
SUM(CASE WHEN lawn_sign_status = 'cancelled' THEN lawn_sign ELSE 0 END) as cancelled
|
||||
'Previous Voters' as voter_type,
|
||||
COUNT(*) as total_responses,
|
||||
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) as positive_responses,
|
||||
COUNT(CASE WHEN question2_vote_again = false THEN 1 END) as negative_responses,
|
||||
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) * 100.0 / COUNT(*) as positive_percentage
|
||||
FROM poll_response pr
|
||||
JOIN poll p ON pr.poll_id = p.poll_id
|
||||
WHERE p.created_at BETWEEN ?1 AND ?2
|
||||
WHERE p.created_at BETWEEN ?1 AND ?2 AND question1_voted_before = true
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Banner Signs' as sign_type,
|
||||
SUM(banner_sign) as total_requested,
|
||||
SUM(CASE WHEN banner_sign_status = 'delivered' THEN banner_sign ELSE 0 END) as delivered,
|
||||
SUM(CASE WHEN banner_sign_status = 'cancelled' THEN banner_sign ELSE 0 END) as cancelled
|
||||
'New Voters' as voter_type,
|
||||
COUNT(*) as total_responses,
|
||||
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) as positive_responses,
|
||||
COUNT(CASE WHEN question2_vote_again = false THEN 1 END) as negative_responses,
|
||||
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) * 100.0 / COUNT(*) as positive_percentage
|
||||
FROM poll_response pr
|
||||
JOIN poll p ON pr.poll_id = p.poll_id
|
||||
WHERE p.created_at BETWEEN ?1 AND ?2 AND question1_voted_before = false
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Overall' as voter_type,
|
||||
COUNT(*) as total_responses,
|
||||
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) as positive_responses,
|
||||
COUNT(CASE WHEN question2_vote_again = false THEN 1 END) as negative_responses,
|
||||
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) * 100.0 / COUNT(*) as positive_percentage
|
||||
FROM poll_response pr
|
||||
JOIN poll p ON pr.poll_id = p.poll_id
|
||||
WHERE p.created_at BETWEEN ?1 AND ?2`,
|
||||
},
|
||||
{
|
||||
ID: "feedback_analysis",
|
||||
Name: "Feedback Analysis",
|
||||
Description: "Analysis of open-text feedback from responses",
|
||||
ID: "donations_by_poll",
|
||||
Name: "Donations by Poll",
|
||||
Description: "Which polls drive donations - donation amounts per poll",
|
||||
SQL: `SELECT
|
||||
LENGTH(thoughts) as feedback_length_category,
|
||||
COUNT(*) as response_count
|
||||
p.poll_id,
|
||||
u.first_name || ' ' || u.last_name as creator_name,
|
||||
ad.address,
|
||||
pr.question6_donation_amount,
|
||||
COUNT(pr.poll_response_id) as response_count,
|
||||
CASE
|
||||
WHEN COUNT(pr.poll_response_id) > 0
|
||||
THEN pr.question6_donation_amount / COUNT(pr.poll_response_id)
|
||||
ELSE 0
|
||||
END as donation_per_response,
|
||||
p.created_at as poll_date
|
||||
FROM poll p
|
||||
JOIN users u ON p.user_id = u.user_id
|
||||
JOIN address_database ad ON p.address_id = ad.address_id
|
||||
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
|
||||
WHERE p.created_at BETWEEN ?1 AND ?2 AND pr.question6_donation_amount > 0
|
||||
GROUP BY p.poll_id, u.first_name, u.last_name, ad.address, pr.question6_donation_amount, p.created_at
|
||||
ORDER BY pr.question6_donation_amount DESC, response_count DESC`,
|
||||
},
|
||||
{
|
||||
ID: "response_donation_correlation",
|
||||
Name: "Response-to-Donation Correlation",
|
||||
Description: "Are positive responses linked to donations?",
|
||||
SQL: `SELECT
|
||||
CASE
|
||||
WHEN question2_vote_again = true THEN 'Will Vote Again'
|
||||
WHEN question2_vote_again = false THEN 'Will Not Vote Again'
|
||||
ELSE 'No Response'
|
||||
END as response_type,
|
||||
COUNT(*) as response_count,
|
||||
COUNT(CASE WHEN pr.question6_donation_amount > 0 THEN 1 END) as responses_with_donations,
|
||||
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
|
||||
AVG(pr.question6_donation_amount) as avg_donation,
|
||||
COUNT(CASE WHEN pr.question6_donation_amount > 0 THEN 1 END) * 100.0 / COUNT(*) as donation_rate_percent
|
||||
FROM poll_response pr
|
||||
JOIN poll p ON pr.poll_id = p.poll_id
|
||||
WHERE p.created_at BETWEEN ?1 AND ?2
|
||||
AND thoughts IS NOT NULL
|
||||
AND TRIM(thoughts) != ''
|
||||
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`,
|
||||
GROUP BY question2_vote_again
|
||||
ORDER BY total_donations DESC`,
|
||||
},
|
||||
},
|
||||
"availability": {
|
||||
{
|
||||
ID: "volunteer_availability",
|
||||
Name: "Volunteer Availability",
|
||||
Description: "Current volunteer availability schedules",
|
||||
ID: "volunteer_availability_schedule",
|
||||
Name: "Volunteer Availability by Date Range",
|
||||
Description: "Who can work when - current volunteer availability schedules",
|
||||
SQL: `SELECT
|
||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||
u.email,
|
||||
av.day_of_week,
|
||||
av.start_time,
|
||||
av.end_time,
|
||||
EXTRACT(EPOCH FROM (av.end_time - av.start_time))/3600 as hours_available,
|
||||
av.created_at as schedule_updated
|
||||
FROM volunteer_availability av
|
||||
FROM availability av
|
||||
JOIN users u ON av.user_id = u.user_id
|
||||
WHERE av.created_at BETWEEN ?1 AND ?2
|
||||
ORDER BY u.first_name, u.last_name, av.day_of_week, av.start_time`,
|
||||
WHERE u.role_id IN (2, 3) AND av.created_at BETWEEN ?1 AND ?2
|
||||
ORDER BY u.first_name, u.last_name,
|
||||
CASE av.day_of_week
|
||||
WHEN 'Monday' THEN 1
|
||||
WHEN 'Tuesday' THEN 2
|
||||
WHEN 'Wednesday' THEN 3
|
||||
WHEN 'Thursday' THEN 4
|
||||
WHEN 'Friday' THEN 5
|
||||
WHEN 'Saturday' THEN 6
|
||||
WHEN 'Sunday' THEN 7
|
||||
END, av.start_time`,
|
||||
},
|
||||
{
|
||||
ID: "peak_availability",
|
||||
Name: "Peak Availability Times",
|
||||
Description: "Times when most volunteers are available",
|
||||
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",
|
||||
ID: "volunteer_fulfillment",
|
||||
Name: "Volunteer Fulfillment (Available vs Actually Worked)",
|
||||
Description: "Reliability measure - comparing availability to actual appointments",
|
||||
SQL: `SELECT
|
||||
u.first_name || ' ' || u.last_name as volunteer_name,
|
||||
a.appointment_date,
|
||||
a.appointment_time,
|
||||
ad.address,
|
||||
'No availability recorded' as conflict_reason
|
||||
FROM appointment a
|
||||
JOIN users u ON a.user_id = u.user_id
|
||||
JOIN address_database ad ON a.address_id = ad.address_id
|
||||
LEFT JOIN volunteer_availability av ON u.user_id = av.user_id
|
||||
AND EXTRACT(dow FROM a.appointment_date) = av.day_of_week
|
||||
AND a.appointment_time BETWEEN av.start_time AND av.end_time
|
||||
WHERE a.appointment_date BETWEEN ?1 AND ?2
|
||||
AND av.user_id IS NULL
|
||||
ORDER BY a.appointment_date, a.appointment_time`,
|
||||
COUNT(DISTINCT av.availability_id) as availability_slots,
|
||||
SUM(EXTRACT(EPOCH FROM (av.end_time - av.start_time))/3600) as total_hours_available,
|
||||
COUNT(DISTINCT a.sched_id) as appointments_scheduled,
|
||||
COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE THEN a.sched_id END) as past_appointments,
|
||||
COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
|
||||
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
||||
AND DATE(p.created_at) = a.appointment_date
|
||||
) THEN a.sched_id END) as completed_appointments,
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE THEN a.sched_id END) > 0
|
||||
THEN COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
|
||||
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
|
||||
AND DATE(p.created_at) = a.appointment_date
|
||||
) THEN a.sched_id END) * 100.0 / COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE THEN a.sched_id END)
|
||||
ELSE 0
|
||||
END as fulfillment_rate_percent
|
||||
FROM users u
|
||||
LEFT JOIN availability av ON u.user_id = av.user_id AND av.created_at BETWEEN ?1 AND ?2
|
||||
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
|
||||
WHERE u.role_id IN (2, 3)
|
||||
GROUP BY u.user_id, u.first_name, u.last_name
|
||||
HAVING COUNT(DISTINCT av.availability_id) > 0 OR COUNT(DISTINCT a.sched_id) > 0
|
||||
ORDER BY fulfillment_rate_percent DESC, total_hours_available DESC`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,10 +2,33 @@ package handlers
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
|
||||
<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'"
|
||||
>
|
||||
<i class="fas fa-file-import mr-2"></i>Import Data
|
||||
@@ -141,8 +141,9 @@
|
||||
<button
|
||||
class="px-3 py-1 bg-gray-100 text-gray-500 text-sm rounded-md cursor-not-allowed"
|
||||
disabled
|
||||
hidden
|
||||
>
|
||||
Assigned
|
||||
<i class="fa-solid fa-plus text-orange-400"></i>
|
||||
</button>
|
||||
<form action="/remove_assigned_address" method="POST" class="inline-block">
|
||||
<input type="hidden" name="address_id" value="{{ .AddressID }}" />
|
||||
@@ -151,16 +152,17 @@
|
||||
type="submit"
|
||||
class="text-red-400 hover:text-red-600 p-1"
|
||||
title="Remove assignment"
|
||||
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{ else }}
|
||||
<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 }}')"
|
||||
>
|
||||
Assign
|
||||
<i class="fa-solid fa-plus text-blue-500"></i>
|
||||
</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
#single-map {
|
||||
height: 50vh; /* Smaller height on mobile */
|
||||
}
|
||||
|
||||
body.sidebar-open .map-controls {
|
||||
display: none;
|
||||
}
|
||||
@@ -98,7 +97,6 @@
|
||||
.dashboard-container {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#single-map {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -134,12 +132,6 @@
|
||||
<button class="control-button" onclick="refreshMap()" title="Refresh Map">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</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 id="single-map"></div>
|
||||
|
||||
@@ -202,26 +194,17 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Global variables - only one set
|
||||
let theMap = null;
|
||||
let markerLayer = null;
|
||||
let popup = null;
|
||||
let initialized = false;
|
||||
|
||||
// Clean initialization
|
||||
function initializeMap() {
|
||||
if (initialized || !window.ol) {
|
||||
console.log("Map already initialized or OpenLayers not ready");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Initializing single map...");
|
||||
if (initialized || !window.ol) return;
|
||||
|
||||
try {
|
||||
// Calgary coordinates
|
||||
const center = ol.proj.fromLonLat([-114.0719, 51.0447]);
|
||||
|
||||
// Create the ONE AND ONLY map
|
||||
theMap = new ol.Map({
|
||||
target: "single-map",
|
||||
layers: [
|
||||
@@ -235,7 +218,6 @@
|
||||
}),
|
||||
});
|
||||
|
||||
// Create popup
|
||||
popup = new ol.Overlay({
|
||||
element: document.getElementById("popup"),
|
||||
positioning: "bottom-center",
|
||||
@@ -244,13 +226,11 @@
|
||||
});
|
||||
theMap.addOverlay(popup);
|
||||
|
||||
// Close popup handler
|
||||
document.getElementById("popup-closer").onclick = function () {
|
||||
popup.setPosition(undefined);
|
||||
return false;
|
||||
};
|
||||
|
||||
// Create marker layer
|
||||
markerLayer = new ol.layer.Vector({
|
||||
source: new ol.source.Vector(),
|
||||
style: new ol.style.Style({
|
||||
@@ -264,15 +244,8 @@
|
||||
});
|
||||
theMap.addLayer(markerLayer);
|
||||
|
||||
// Click handler
|
||||
theMap.on("click", function (event) {
|
||||
const feature = theMap.forEachFeatureAtPixel(
|
||||
event.pixel,
|
||||
function (feature) {
|
||||
return feature;
|
||||
}
|
||||
);
|
||||
|
||||
const feature = theMap.forEachFeatureAtPixel(event.pixel, f => f);
|
||||
if (feature && feature.get("address_data")) {
|
||||
const data = feature.get("address_data");
|
||||
document.getElementById("popup-content").innerHTML = `
|
||||
@@ -282,8 +255,7 @@
|
||||
<p><strong>House #:</strong> ${data.house_number}</p>
|
||||
<p><strong>Street:</strong> ${data.street_name} ${data.street_type}</p>
|
||||
<p><strong>ID:</strong> ${data.address_id}</p>
|
||||
</div>
|
||||
`;
|
||||
</div>`;
|
||||
popup.setPosition(event.coordinate);
|
||||
} else {
|
||||
popup.setPosition(undefined);
|
||||
@@ -291,45 +263,32 @@
|
||||
});
|
||||
|
||||
initialized = true;
|
||||
console.log("Map initialized successfully");
|
||||
|
||||
// Load markers
|
||||
setTimeout(loadMarkers, 500);
|
||||
} catch (error) {
|
||||
console.error("Map initialization error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load validated addresses
|
||||
async function loadMarkers() {
|
||||
try {
|
||||
const response = await fetch("/api/validated-addresses");
|
||||
const addresses = await response.json();
|
||||
|
||||
console.log(`Loading ${addresses.length} addresses`);
|
||||
document.getElementById("marker-count").textContent = `${addresses.length} on map`;
|
||||
|
||||
// Clear existing markers
|
||||
markerLayer.getSource().clear();
|
||||
|
||||
// Add new markers
|
||||
const features = [];
|
||||
addresses.forEach((addr) => {
|
||||
if (addr.longitude && addr.latitude) {
|
||||
const coords = ol.proj.fromLonLat([addr.longitude, addr.latitude]);
|
||||
const feature = new ol.Feature({
|
||||
geometry: new ol.geom.Point(coords),
|
||||
const features = addresses
|
||||
.filter(addr => addr.longitude && addr.latitude)
|
||||
.map(addr => new ol.Feature({
|
||||
geometry: new ol.geom.Point(ol.proj.fromLonLat([addr.longitude, addr.latitude])),
|
||||
address_data: addr,
|
||||
});
|
||||
features.push(feature);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
markerLayer.getSource().addFeatures(features);
|
||||
|
||||
if (features.length > 0) {
|
||||
const extent = markerLayer.getSource().getExtent();
|
||||
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] });
|
||||
theMap.getView(extent, { padding: [20, 20, 20, 20] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading markers:", error);
|
||||
@@ -337,45 +296,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Control functions
|
||||
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();
|
||||
}
|
||||
|
||||
function fitAllMarkers() {
|
||||
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 () {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
setTimeout(initializeMap, 1000);
|
||||
});
|
||||
|
||||
// Listen for sidebar state changes to manage map controls visibility
|
||||
function handleSidebarToggle() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const body = document.body;
|
||||
|
||||
if (sidebar && sidebar.classList.contains('active')) {
|
||||
body.classList.add('sidebar-open');
|
||||
} else {
|
||||
body.classList.remove('sidebar-open');
|
||||
}
|
||||
document.body.classList.toggle('sidebar-open', sidebar && sidebar.classList.contains('active'));
|
||||
}
|
||||
|
||||
// Override the original toggleSidebar function to handle map controls
|
||||
if (typeof window.toggleSidebar === 'function') {
|
||||
const originalToggleSidebar = window.toggleSidebar;
|
||||
window.toggleSidebar = function() {
|
||||
|
||||
@@ -167,11 +167,6 @@
|
||||
</a>
|
||||
{{ 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">
|
||||
<i class="fas fa-sign-out-alt w-5 text-gray-400 mr-3"></i>
|
||||
<span>Logout</span>
|
||||
@@ -183,7 +178,7 @@
|
||||
<!-- Main Content Container -->
|
||||
<div class="main-content-container min-h-screen flex flex-col bg-custom-gray">
|
||||
<!-- 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">
|
||||
<!-- Hamburger (left aligned with consistent spacing) -->
|
||||
<div class="flex items-center">
|
||||
@@ -195,9 +190,9 @@
|
||||
<!-- Right side -->
|
||||
<div class="flex items-center space-x-2 md:space-x-4">
|
||||
<!-- Dark mode -->
|
||||
<button class="text-text-secondary hover:text-text-primary p-2">
|
||||
<i class="fas fa-moon text-lg"></i>
|
||||
</button>
|
||||
<a href="/logout" class="text-text-secondary hover:text-text-primary p-2">
|
||||
<i class="fa-solid fa-arrow-right-from-bracket text-lg"></i>
|
||||
</a>
|
||||
|
||||
<!-- Profile (hover dropdown on desktop, click on mobile) -->
|
||||
<div class="relative group cursor-pointer">
|
||||
@@ -211,7 +206,7 @@
|
||||
<!-- 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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +215,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Page Content -->
|
||||
<div class="flex-1">
|
||||
<div class="flex-1 mt-20">
|
||||
{{ template "content" . }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -417,14 +412,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Profile menu toggle
|
||||
function toggleProfileMenu() {
|
||||
const menu = document.getElementById('profile-menu');
|
||||
if (menu) {
|
||||
menu.classList.toggle('opacity-0');
|
||||
menu.classList.toggle('invisible');
|
||||
menu.classList.toggle('opacity-100');
|
||||
menu.classList.toggle('visible');
|
||||
const isVisible = !menu.classList.contains('invisible');
|
||||
if (isVisible) {
|
||||
// Hide it
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
>
|
||||
<option value="">Select Category</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="polls" {{if eq .Category "polls"}}selected{{end}}>Polls</option>
|
||||
<option value="availability" {{if eq .Category "availability"}}selected{{end}}>Availability</option>
|
||||
@@ -60,12 +60,12 @@
|
||||
<div class="text-gray-600">
|
||||
<span>{{.Result.Count}} results</span>
|
||||
</div>
|
||||
<button onclick="exportResults()" class="px-3 py-1.5 bg-green-600 text-white hover:bg-green-700 transition-colors">
|
||||
<!-- <button onclick="exportResults()" class="px-3 py-1.5 bg-green-600 text-white hover:bg-green-700 transition-colors">
|
||||
<i class="fas fa-download mr-1"></i>Export CSV
|
||||
</button>
|
||||
<button onclick="printReport()" class="px-3 py-1.5 bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-print mr-1"></i>Print
|
||||
</button>
|
||||
</button> -->
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -146,34 +146,32 @@
|
||||
<script>
|
||||
const reportDefinitions = {
|
||||
users: [
|
||||
{ id: 'participation', name: 'Volunteer Participation Rate' },
|
||||
{ id: 'top_performers', name: 'Top Performing Volunteers' },
|
||||
{ id: 'efficiency', name: 'Response-to-Donation Ratio' },
|
||||
{ id: 'coverage', name: 'User Address Coverage' }
|
||||
{ id: 'volunteer_participation_rate', name: 'Volunteer Participation Rate' },
|
||||
{ id: 'top_performing_volunteers', name: 'Top-Performing Volunteers & Team Leads' },
|
||||
{ id: 'response_donation_ratio', name: 'Response-to-Donation Ratio per Volunteer' },
|
||||
{ id: 'user_address_coverage', name: 'User Address Coverage' }
|
||||
],
|
||||
address: [
|
||||
{ id: 'responses_by_address', name: 'Total Responses by Address' },
|
||||
addresses: [
|
||||
{ id: 'poll_responses_by_address', name: 'Total Poll Responses by Address' },
|
||||
{ id: 'donations_by_address', name: 'Total Donations by Address' },
|
||||
{ id: 'street_breakdown', name: 'Street-Level Breakdown' },
|
||||
{ id: 'quadrant_summary', name: 'Quadrant Summary' }
|
||||
{ id: 'street_level_breakdown', name: 'Street-Level Breakdown (Responses & Donations)' },
|
||||
{ id: 'quadrant_summary', name: 'Quadrant-Level Summary (NE, NW, SE, SW)' }
|
||||
],
|
||||
appointments: [
|
||||
{ id: 'upcoming', name: 'Upcoming Appointments' },
|
||||
{ id: 'completion', name: 'Appointments Completion Rate' },
|
||||
{ id: 'geo_distribution', name: 'Appointments by Quadrant' },
|
||||
{ id: 'lead_time', name: 'Average Lead Time' }
|
||||
{ id: 'upcoming_appointments', name: 'Upcoming Appointments per Volunteer/Team Lead' },
|
||||
{ id: 'missed_vs_completed', name: 'Missed vs Completed Appointments' },
|
||||
{ id: 'appointments_by_quadrant', name: 'Appointments by Quadrant/Region' },
|
||||
{ id: 'scheduling_lead_time', name: 'Average Lead Time (Scheduled vs Actual Date)' }
|
||||
],
|
||||
polls: [
|
||||
{ id: 'distribution', name: 'Response Distribution' },
|
||||
{ id: 'average', name: 'Average Poll Response' },
|
||||
{ id: 'response_distribution', name: 'Response Distribution (Yes/No/Neutral)' },
|
||||
{ id: 'average_poll_response', name: 'Average Poll Response (Yes/No %)' },
|
||||
{ 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: [
|
||||
{ id: 'by_date', name: 'Volunteer Availability by Date' },
|
||||
{ id: 'gaps', name: 'Coverage Gaps' },
|
||||
{ id: 'overlaps', name: 'Volunteer Overlaps' },
|
||||
{ id: 'fulfillment', name: 'Volunteer Fulfillment' }
|
||||
{ id: 'volunteer_availability_schedule', name: 'Volunteer Availability by Date Range' },
|
||||
{ id: 'volunteer_fulfillment', name: 'Volunteer Fulfillment (Available vs Actually Worked)' }
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -153,6 +153,7 @@ func main() {
|
||||
|
||||
http.HandleFunc("/addresses/upload-csv", adminMiddleware(handlers.CSVUploadHandler))
|
||||
http.HandleFunc("/reports", adminMiddleware(handlers.ReportsHandler))
|
||||
|
||||
http.HandleFunc("/posts", adminMiddleware(handlers.PostsHandler))
|
||||
|
||||
http.HandleFunc("/api/validated-addresses", handlers.GetValidatedAddressesHandler)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
exit status 1
|
||||
Reference in New Issue
Block a user