Update: Few issues to resolve see readme
this push will conclude the majority of pulls. this repos will now, not be actively be managed or any further code pushes will not be frequent.
This commit is contained in:
19
README.MD
19
README.MD
@@ -1,16 +1,7 @@
|
|||||||
# Poll-system
|
# Poll-system
|
||||||
|
|
||||||
- TODO: volunteer Available
|
- TODO: Add Error popups
|
||||||
- TODO: Update assign address func to take into account availability
|
- TODO: Add A email notification when the user gets assigned a address
|
||||||
|
- TODO: Add a view when the volunteer signed schedule of the other meembers as a heat map
|
||||||
|
- TODO: square API to retrive the amount and also to show admin
|
||||||
- 18'' Metal Ruler
|
- TODO: Square payment page qr code
|
||||||
- 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)
|
|
||||||
|
|||||||
@@ -13,12 +13,7 @@ CREATE TABLE users (
|
|||||||
first_name VARCHAR(100),
|
first_name VARCHAR(100),
|
||||||
last_name VARCHAR(100),
|
last_name VARCHAR(100),
|
||||||
email VARCHAR(150) UNIQUE NOT NULL,
|
email VARCHAR(150) UNIQUE NOT NULL,
|
||||||
phone VARCHAR(20),
|
phone VARCHAR(20) UNIQUE NOT NULL,
|
||||||
password TEXT NOT NULL,
|
|
||||||
role_id INT REFERENCES role(role_id) ON DELETE SET NULL,
|
|
||||||
admin_code CHAR(6) UNIQUE,
|
|
||||||
-- Admin settings combined here
|
|
||||||
ward_settings TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -100,7 +95,7 @@ CREATE TABLE post (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE availability (
|
CREATE TABLE availability (
|
||||||
availability_id SERIAL PRIMARY KEY,
|
sched_id SERIAL PRIMARY KEY,
|
||||||
user_id INT REFERENCES users(user_id) ON DELETE CASCADE,
|
user_id INT REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
day_of_week VARCHAR(20),
|
day_of_week VARCHAR(20),
|
||||||
start_time TIME,
|
start_time TIME,
|
||||||
@@ -139,3 +134,6 @@ INSERT INTO role (role_id, name) VALUES
|
|||||||
(2, 'team_lead'),
|
(2, 'team_lead'),
|
||||||
(3, 'volunteer')
|
(3, 'volunteer')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
ALTER TABLE availability
|
||||||
|
ADD CONSTRAINT availability_user_day_unique UNIQUE (user_id, day_of_week);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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"
|
||||||
@@ -36,19 +35,18 @@ type PageNumber struct {
|
|||||||
// AddressWithDetails extends AddressDatabase with appointment and user info
|
// AddressWithDetails extends AddressDatabase with appointment and user info
|
||||||
type AddressWithDetails struct {
|
type AddressWithDetails struct {
|
||||||
models.AddressDatabase
|
models.AddressDatabase
|
||||||
UserID *int
|
UserID *int
|
||||||
UserName string
|
UserName string
|
||||||
UserEmail string
|
UserEmail string
|
||||||
AppointmentDate string
|
AppointmentDate string
|
||||||
AppointmentTime string
|
AppointmentTime string
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddressHandler(w http.ResponseWriter, r *http.Request) {
|
func AddressHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
// Get pagination parameters from query string
|
// Get pagination parameters from query string
|
||||||
pageStr := r.URL.Query().Get("page")
|
pageStr := r.URL.Query().Get("page")
|
||||||
pageSizeStr := r.URL.Query().Get("pageSize")
|
pageSizeStr := r.URL.Query().Get("pageSize")
|
||||||
username,_ := models.GetCurrentUserName(r)
|
username, _ := models.GetCurrentUserName(r)
|
||||||
|
|
||||||
|
|
||||||
page := 1
|
page := 1
|
||||||
pageSize := 20
|
pageSize := 20
|
||||||
@@ -157,7 +155,7 @@ func AddressHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get users associated with this admin
|
// Get users associated with this admin
|
||||||
currentAdminID := r.Context().Value("user_id").(int)
|
currentAdminID := models.GetCurrentUserID(w, r)
|
||||||
userRows, err := models.DB.Query(`
|
userRows, err := models.DB.Query(`
|
||||||
SELECT u.user_id, u.first_name || ' ' || u.last_name AS name
|
SELECT u.user_id, u.first_name || ' ' || u.last_name AS name
|
||||||
FROM users u
|
FROM users u
|
||||||
@@ -216,7 +214,7 @@ func AddressHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
"ActiveSection": "address",
|
"ActiveSection": "address",
|
||||||
"Addresses": addresses,
|
"Addresses": addresses,
|
||||||
"Users": users,
|
"Users": users,
|
||||||
"UserName": username,
|
"UserName": username,
|
||||||
"Role": "admin",
|
"Role": "admin",
|
||||||
"Pagination": pagination,
|
"Pagination": pagination,
|
||||||
})
|
})
|
||||||
@@ -267,13 +265,9 @@ func AssignAddressHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
appointmentDate := r.FormValue("appointment_date")
|
appointmentDate := r.FormValue("appointment_date")
|
||||||
startTime := r.FormValue("time")
|
startTime := r.FormValue("time")
|
||||||
|
|
||||||
if userIDStr == "" || addressIDStr == "" {
|
// Basic validation
|
||||||
http.Error(w, "User ID and Address ID are required", http.StatusBadRequest)
|
if userIDStr == "" || addressIDStr == "" || appointmentDate == "" || startTime == "" {
|
||||||
return
|
http.Error(w, "All fields are required", http.StatusBadRequest)
|
||||||
}
|
|
||||||
|
|
||||||
if appointmentDate == "" || startTime == "" {
|
|
||||||
http.Error(w, "Appointment date and start time are required", http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,54 +283,30 @@ func AssignAddressHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate the appointment date
|
// Parse date
|
||||||
parsedDate, err := time.Parse("2006-01-02", appointmentDate)
|
parsedDate, err := time.Parse("2006-01-02", appointmentDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid appointment date format", http.StatusBadRequest)
|
http.Error(w, "Invalid appointment date format", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that the appointment date is not in the past
|
// Parse time
|
||||||
today := time.Now().Truncate(24 * time.Hour)
|
|
||||||
if parsedDate.Before(today) {
|
|
||||||
http.Error(w, "Appointment date cannot be in the past", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and validate the start time
|
|
||||||
parsedTime, err := time.Parse("15:04", startTime)
|
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
|
|
||||||
currentAdminID := r.Context().Value("user_id").(int)
|
|
||||||
var userExists int
|
|
||||||
err = models.DB.QueryRow(`
|
|
||||||
SELECT COUNT(*) FROM admin_volunteers av
|
|
||||||
JOIN users u ON av.volunteer_id = u.user_id
|
|
||||||
WHERE av.admin_id = $1 AND u.user_id = $2 AND av.is_active = true
|
|
||||||
`, currentAdminID, userID).Scan(&userExists)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("User verification error:", err)
|
http.Error(w, "Invalid appointment time format", http.StatusBadRequest)
|
||||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if userExists == 0 {
|
|
||||||
http.Error(w, "Invalid user selection", http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this address is already assigned to any user
|
// --- Availability Check (non-blocking) ---
|
||||||
|
isValid := ValidateAvailability(parsedDate, parsedTime, userID)
|
||||||
|
if !isValid {
|
||||||
|
// Instead of blocking, just log it
|
||||||
|
log.Printf("⚠️ User %d is not available on %s at %s", userID, appointmentDate, startTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this address is already assigned
|
||||||
var exists int
|
var exists int
|
||||||
err = models.DB.QueryRow(`
|
err = models.DB.QueryRow(`SELECT COUNT(*) FROM appointment WHERE address_id = $1`, addressID).Scan(&exists)
|
||||||
SELECT COUNT(*) FROM appointment
|
|
||||||
WHERE address_id = $1
|
|
||||||
`, addressID).Scan(&exists)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Assignment check error:", err)
|
log.Println("Assignment check error:", err)
|
||||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
@@ -347,38 +317,39 @@ func AssignAddressHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the user already has an appointment at the same date and time
|
// Check for conflicting appointment for the user
|
||||||
var timeConflict int
|
var conflict 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(&conflict)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Time conflict check error:", err)
|
log.Println("Conflict check error:", err)
|
||||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if timeConflict > 0 {
|
if conflict > 0 {
|
||||||
http.Error(w, "User already has an appointment at this date and time", http.StatusBadRequest)
|
http.Error(w, "User already has an appointment at this date and time", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign the address - create appointment with specific date and time
|
// Insert the appointment anyway
|
||||||
_, err = models.DB.Exec(`
|
_, err = models.DB.Exec(`
|
||||||
INSERT INTO appointment (user_id, address_id, appointment_date, appointment_time, created_at, updated_at)
|
INSERT INTO appointment (user_id, address_id, appointment_date, appointment_time, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
VALUES ($1, $2, $3, $4, NOW(), NOW())`,
|
||||||
`, userID, addressID, appointmentDate, startTime)
|
userID, addressID, appointmentDate, startTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Assignment error:", err)
|
log.Println("Insert appointment error:", err)
|
||||||
http.Error(w, "Failed to assign address", http.StatusInternalServerError)
|
http.Error(w, "Failed to assign address", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect back to addresses page with success
|
// ✅ Later: you can pass `UserNotAvailable: !isValid` to utils.Render instead of redirect
|
||||||
|
log.Printf("✅ Address %d assigned to user %d for %s at %s (Available: %v)",
|
||||||
|
addressID, userID, appointmentDate, startTime, isValid)
|
||||||
|
|
||||||
http.Redirect(w, r, "/addresses?success=assigned", http.StatusSeeOther)
|
http.Redirect(w, r, "/addresses?success=assigned", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func RemoveAssignedAddressHandler(w http.ResponseWriter, r *http.Request) {
|
func RemoveAssignedAddressHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Redirect(w, r, "/addresses", http.StatusSeeOther)
|
http.Redirect(w, r, "/addresses", http.StatusSeeOther)
|
||||||
|
|||||||
@@ -12,22 +12,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type VolunteerStatistics struct {
|
type VolunteerStatistics struct {
|
||||||
AppointmentsToday int
|
AppointmentsToday int
|
||||||
AppointmentsTomorrow int
|
AppointmentsTomorrow int
|
||||||
AppointmentsThisWeek int
|
AppointmentsThisWeek int
|
||||||
TotalAppointments int
|
TotalAppointments int
|
||||||
PollsCompleted int
|
PollsCompleted int
|
||||||
PollsRemaining int
|
PollsRemaining int
|
||||||
LawnSignsRequested int
|
LawnSignsRequested int
|
||||||
BannerSignsRequested int
|
BannerSignsRequested int
|
||||||
PollCompletionPercent int
|
PollCompletionPercent int
|
||||||
}
|
}
|
||||||
type TeamMate struct {
|
type TeamMate struct {
|
||||||
UserID int
|
UserID int
|
||||||
FullName string
|
FullName string
|
||||||
Phone string
|
Phone string
|
||||||
Role string
|
Role string
|
||||||
IsLead bool
|
IsLead bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// VolunteerPostsHandler - Dashboard view for volunteers with posts and statistics
|
// VolunteerPostsHandler - Dashboard view for volunteers with posts and statistics
|
||||||
@@ -79,7 +79,7 @@ func VolunteerPostsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch teammates
|
// Fetch teammates
|
||||||
teammatesRows, err := models.DB.Query(`
|
teammatesRows, err := models.DB.Query(`
|
||||||
SELECT u.user_id,
|
SELECT u.user_id,
|
||||||
u.first_name || ' ' || u.last_name AS full_name,
|
u.first_name || ' ' || u.last_name AS full_name,
|
||||||
COALESCE(u.phone, '') AS phone,
|
COALESCE(u.phone, '') AS phone,
|
||||||
@@ -94,21 +94,20 @@ func VolunteerPostsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
)
|
)
|
||||||
ORDER BY CASE WHEN r.name = 'team_lead' THEN 0 ELSE 1 END, u.first_name;
|
ORDER BY CASE WHEN r.name = 'team_lead' THEN 0 ELSE 1 END, u.first_name;
|
||||||
`, CurrentUserID, CurrentUserID)
|
`, CurrentUserID, CurrentUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Database query error (teammates): %v\n", err)
|
fmt.Printf("Database query error (teammates): %v\n", err)
|
||||||
}
|
}
|
||||||
defer teammatesRows.Close()
|
defer teammatesRows.Close()
|
||||||
|
|
||||||
var teammates []TeamMate
|
|
||||||
for teammatesRows.Next() {
|
|
||||||
var t TeamMate
|
|
||||||
if err := teammatesRows.Scan(&t.UserID, &t.FullName, &t.Phone, &t.Role); err != nil {
|
|
||||||
fmt.Printf("Row scan error (teammates): %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
teammates = append(teammates, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
var teammates []TeamMate
|
||||||
|
for teammatesRows.Next() {
|
||||||
|
var t TeamMate
|
||||||
|
if err := teammatesRows.Scan(&t.UserID, &t.FullName, &t.Phone, &t.Role); err != nil {
|
||||||
|
fmt.Printf("Row scan error (teammates): %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
teammates = append(teammates, t)
|
||||||
|
}
|
||||||
|
|
||||||
// Get volunteer statistics
|
// Get volunteer statistics
|
||||||
stats, err := getVolunteerStatistics(CurrentUserID)
|
stats, err := getVolunteerStatistics(CurrentUserID)
|
||||||
@@ -125,23 +124,23 @@ func VolunteerPostsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
fmt.Printf("Volunteer viewing %d posts\n", len(posts))
|
fmt.Printf("Volunteer viewing %d posts\n", len(posts))
|
||||||
|
|
||||||
utils.Render(w, "volunteer_dashboard.html", map[string]interface{}{
|
utils.Render(w, "volunteer_dashboard.html", map[string]interface{}{
|
||||||
"Title": "Volunteer Dashboard",
|
"Title": "Volunteer Dashboard",
|
||||||
"IsAuthenticated": true,
|
"IsAuthenticated": true,
|
||||||
"ShowAdminNav": showAdminNav,
|
"ShowAdminNav": showAdminNav,
|
||||||
"ShowVolunteerNav": showVolunteerNav,
|
"ShowVolunteerNav": showVolunteerNav,
|
||||||
"UserName": username,
|
"UserName": username,
|
||||||
"Posts": posts,
|
"Posts": posts,
|
||||||
"Statistics": stats,
|
"Statistics": stats,
|
||||||
"Teammates": teammates,
|
"Teammates": teammates,
|
||||||
"ActiveSection": "dashboard",
|
"ActiveSection": "dashboard",
|
||||||
"IsVolunteer": true,
|
"IsVolunteer": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func getVolunteerStatistics(userID int) (*VolunteerStatistics, error) {
|
func getVolunteerStatistics(userID int) (*VolunteerStatistics, error) {
|
||||||
stats := &VolunteerStatistics{}
|
stats := &VolunteerStatistics{}
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
// Get start of current week (Monday)
|
// Get start of current week (Monday)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
oneDayLater := now.Add(time.Hour * 12)
|
oneDayLater := now.Add(time.Hour * 12)
|
||||||
@@ -160,11 +159,10 @@ func getVolunteerStatistics(userID int) (*VolunteerStatistics, error) {
|
|||||||
fmt.Println("Week Start:", weekStart.Format("2006-01-02"))
|
fmt.Println("Week Start:", weekStart.Format("2006-01-02"))
|
||||||
fmt.Println("Week End:", weekEnd.Format("2006-01-02"))
|
fmt.Println("Week End:", weekEnd.Format("2006-01-02"))
|
||||||
|
|
||||||
|
|
||||||
// Appointments today
|
// Appointments today
|
||||||
err := models.DB.QueryRow(`
|
err := models.DB.QueryRow(`
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM appointment
|
FROM appointment
|
||||||
WHERE user_id = $1 AND DATE(appointment_date) = $2
|
WHERE user_id = $1 AND DATE(appointment_date) = $2
|
||||||
`, userID, today).Scan(&stats.AppointmentsToday)
|
`, userID, today).Scan(&stats.AppointmentsToday)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -173,8 +171,8 @@ func getVolunteerStatistics(userID int) (*VolunteerStatistics, error) {
|
|||||||
|
|
||||||
// Appointments tomorrow
|
// Appointments tomorrow
|
||||||
err = models.DB.QueryRow(`
|
err = models.DB.QueryRow(`
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM appointment
|
FROM appointment
|
||||||
WHERE user_id = $1 AND DATE(appointment_date) = $2
|
WHERE user_id = $1 AND DATE(appointment_date) = $2
|
||||||
`, userID, oneDayLater).Scan(&stats.AppointmentsTomorrow)
|
`, userID, oneDayLater).Scan(&stats.AppointmentsTomorrow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -183,19 +181,18 @@ func getVolunteerStatistics(userID int) (*VolunteerStatistics, error) {
|
|||||||
|
|
||||||
// Appointments this week
|
// Appointments this week
|
||||||
err = models.DB.QueryRow(`
|
err = models.DB.QueryRow(`
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM appointment
|
FROM appointment
|
||||||
WHERE user_id = $1 AND DATE(appointment_date) >= $2 AND DATE(appointment_date) <= $3
|
WHERE user_id = $1 AND DATE(appointment_date) >= $2 AND DATE(appointment_date) <= $3
|
||||||
`, userID, weekStart, weekEnd).Scan(&stats.AppointmentsThisWeek)
|
`, userID, weekStart, weekEnd).Scan(&stats.AppointmentsThisWeek)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Total appointments
|
// Total appointments
|
||||||
err = models.DB.QueryRow(`
|
err = models.DB.QueryRow(`
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM appointment
|
FROM appointment
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
`, userID).Scan(&stats.TotalAppointments)
|
`, userID).Scan(&stats.TotalAppointments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -214,8 +211,12 @@ func getVolunteerStatistics(userID int) (*VolunteerStatistics, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Polls remaining (appointments without poll responses)
|
// Polls remaining (appointments without poll responses)
|
||||||
|
|
||||||
stats.PollsRemaining = stats.TotalAppointments - stats.PollsCompleted
|
stats.PollsRemaining = stats.TotalAppointments - stats.PollsCompleted
|
||||||
|
|
||||||
|
fmt.Print(stats.PollsRemaining)
|
||||||
|
|
||||||
|
|
||||||
// Calculate completion percentage
|
// Calculate completion percentage
|
||||||
if stats.TotalAppointments > 0 {
|
if stats.TotalAppointments > 0 {
|
||||||
stats.PollCompletionPercent = (stats.PollsCompleted * 100) / stats.TotalAppointments
|
stats.PollCompletionPercent = (stats.PollsCompleted * 100) / stats.TotalAppointments
|
||||||
@@ -225,7 +226,7 @@ func getVolunteerStatistics(userID int) (*VolunteerStatistics, error) {
|
|||||||
|
|
||||||
// Signs requested
|
// Signs requested
|
||||||
err = models.DB.QueryRow(`
|
err = models.DB.QueryRow(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(pr.question3_lawn_signs), 0),
|
COALESCE(SUM(pr.question3_lawn_signs), 0),
|
||||||
COALESCE(SUM(pr.question4_banner_signs), 0)
|
COALESCE(SUM(pr.question4_banner_signs), 0)
|
||||||
FROM poll p
|
FROM poll p
|
||||||
@@ -237,4 +238,4 @@ func getVolunteerStatistics(userID int) (*VolunteerStatistics, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
func PollHandler(w http.ResponseWriter, r *http.Request) {
|
func PollHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
username, _ := models.GetCurrentUserName(r)
|
username, _ := models.GetCurrentUserName(r)
|
||||||
|
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
addressID := r.URL.Query().Get("address_id")
|
addressID := r.URL.Query().Get("address_id")
|
||||||
if addressID == "" {
|
if addressID == "" {
|
||||||
@@ -27,7 +27,7 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var userID int
|
var userID int
|
||||||
fmt.Print(addressID, userID)
|
fmt.Print(addressID, userID)
|
||||||
err := models.DB.QueryRow(`
|
err := models.DB.QueryRow(`
|
||||||
SELECT a.address, ap.user_id
|
SELECT a.address, ap.user_id
|
||||||
FROM appointment AS ap
|
FROM appointment AS ap
|
||||||
JOIN address_database a ON a.address_id = ap.address_id
|
JOIN address_database a ON a.address_id = ap.address_id
|
||||||
WHERE ap.address_id = $1
|
WHERE ap.address_id = $1
|
||||||
@@ -41,8 +41,8 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Check if poll already exists for this address
|
// Check if poll already exists for this address
|
||||||
var pollID int
|
var pollID int
|
||||||
err = models.DB.QueryRow(`
|
err = models.DB.QueryRow(`
|
||||||
SELECT poll_id
|
SELECT poll_id
|
||||||
FROM poll
|
FROM poll
|
||||||
WHERE address_id = $1 AND user_id = $2
|
WHERE address_id = $1 AND user_id = $2
|
||||||
`, addressID, userID).Scan(&pollID)
|
`, addressID, userID).Scan(&pollID)
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
VALUES ($1, $2, 'Door-to-Door Poll', 'Campaign polling questions', true)
|
VALUES ($1, $2, 'Door-to-Door Poll', 'Campaign polling questions', true)
|
||||||
RETURNING poll_id
|
RETURNING poll_id
|
||||||
`, userID, addressID).Scan(&pollID)
|
`, userID, addressID).Scan(&pollID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create poll: %v", err)
|
log.Printf("Failed to create poll: %v", err)
|
||||||
http.Error(w, "Failed to create poll", http.StatusInternalServerError)
|
http.Error(w, "Failed to create poll", http.StatusInternalServerError)
|
||||||
@@ -66,15 +66,15 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
utils.Render(w, "poll_form.html", map[string]interface{}{
|
utils.Render(w, "poll_form.html", map[string]interface{}{
|
||||||
"Title": "Poll Questions",
|
"Title": "Poll Questions",
|
||||||
"IsAuthenticated": true,
|
"IsAuthenticated": true,
|
||||||
"ShowAdminNav": true,
|
"ShowVolunteerNav": true,
|
||||||
"UserName": username,
|
"ActiveSection": "schedule",
|
||||||
"ActiveSection": "appointments",
|
"UserName": username,
|
||||||
"PollID": pollID,
|
"PollID": pollID,
|
||||||
"AddressID": addressID,
|
"AddressID": addressID,
|
||||||
"Address": address,
|
"Address": address,
|
||||||
"PageIcon": "fas fa-poll",
|
"PageIcon": "fas fa-poll",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -123,35 +123,35 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Insert poll response
|
// Insert poll response
|
||||||
_, err = models.DB.Exec(`
|
_, err = models.DB.Exec(`
|
||||||
INSERT INTO poll_response (
|
INSERT INTO poll_response (
|
||||||
poll_id, respondent_postal_code, question1_voted_before,
|
poll_id, respondent_postal_code, question1_voted_before,
|
||||||
question2_vote_again, question3_lawn_signs, question4_banner_signs,
|
question2_vote_again, question3_lawn_signs, question4_banner_signs,
|
||||||
question5_thoughts, question6_donation_amount
|
question5_thoughts, question6_donation_amount
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
`, pollID, postalCode, question1VotedBefore, question2VoteAgain,
|
`, pollID, postalCode, question1VotedBefore, question2VoteAgain,
|
||||||
question3LawnSigns, question4BannerSigns, question5Thoughts, question6donation)
|
question3LawnSigns, question4BannerSigns, question5Thoughts, question6donation)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Print(err)
|
fmt.Print(err)
|
||||||
http.Error(w, "Failed to save poll response", http.StatusInternalServerError)
|
http.Error(w, "Failed to save poll response", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}else{
|
} else {
|
||||||
_, err := models.DB.Exec(`
|
_, err := models.DB.Exec(`
|
||||||
UPDATE address_database
|
UPDATE address_database
|
||||||
SET visited_validated = true
|
SET visited_validated = true
|
||||||
WHERE address_id IN (
|
WHERE address_id IN (
|
||||||
SELECT address_id
|
SELECT address_id
|
||||||
FROM poll
|
FROM poll
|
||||||
WHERE poll_id = $1
|
WHERE poll_id = $1
|
||||||
)
|
)
|
||||||
`, pollID)
|
`, pollID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Print(err)
|
fmt.Print(err)
|
||||||
http.Error(w, "Failed to save poll response", http.StatusInternalServerError)
|
http.Error(w, "Failed to save poll response", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/volunteer/Addresses", http.StatusSeeOther)
|
http.Redirect(w, r, "/volunteer/Addresses", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,132 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/patel-mann/poll-system/app/internal/models"
|
"github.com/patel-mann/poll-system/app/internal/models"
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ValidatedFreeTime(parsedDate time.Time, assignTime time.Time, userID int) (bool) {
|
/////////////////////
|
||||||
var startTime, endTime time.Time
|
// Core Validation //
|
||||||
|
/////////////////////
|
||||||
|
|
||||||
dateOnly := parsedDate.Format("2006-01-02")
|
// ValidateAvailability checks if a user is available at a specific time
|
||||||
|
func ValidateAvailability(checkDate time.Time, checkTime time.Time, userID int) bool {
|
||||||
|
var startTime, endTime time.Time
|
||||||
|
day := checkDate.Format("2006-01-02")
|
||||||
|
|
||||||
err := models.DB.QueryRow(
|
err := models.DB.QueryRow(`
|
||||||
`SELECT start_time, end_time
|
SELECT start_time, end_time
|
||||||
FROM availability
|
FROM availability
|
||||||
WHERE user_id = $1 AND day = $2`,
|
WHERE user_id = $1 AND day_of_week = $2`,
|
||||||
userID, dateOnly,
|
userID, day).Scan(&startTime, &endTime)
|
||||||
).Scan(&startTime, &endTime)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Database query failed: %v\n", err)
|
if err != sql.ErrNoRows {
|
||||||
return false
|
log.Printf("DB error in ValidateAvailability: %v", err)
|
||||||
}
|
|
||||||
|
|
||||||
if assignTime.After(startTime) && assignTime.Before(endTime) {
|
|
||||||
return true
|
|
||||||
}else{
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return checkTime.After(startTime) && checkTime.Before(endTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////
|
||||||
|
// Volunteer CRUD //
|
||||||
|
////////////////////
|
||||||
|
|
||||||
|
// View volunteer availability
|
||||||
|
func VolunteerGetAvailabilityHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := models.GetCurrentUserID(w, r)
|
||||||
|
username, _ := models.GetCurrentUserName(r)
|
||||||
|
role, _ := r.Context().Value("user_role").(int)
|
||||||
|
|
||||||
|
rows, err := models.DB.Query(`
|
||||||
|
SELECT availability_id, day_of_week, start_time, end_time, created_at
|
||||||
|
FROM availability
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY day_of_week DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error fetching availability:", err)
|
||||||
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var availability []models.Availability
|
||||||
|
for rows.Next() {
|
||||||
|
var a models.Availability
|
||||||
|
if err := rows.Scan(&a.AvailabilityID, &a.DayOfWeek, &a.StartTime, &a.EndTime, &a.CreatedAt); err != nil {
|
||||||
|
log.Println("Row scan error:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
availability = append(availability, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Render(w, "volunteer_schedule.html", map[string]interface{}{
|
||||||
|
"Title": "My Schedule",
|
||||||
|
"ShowVolunteerNav": true,
|
||||||
|
"IsVolunteer": true,
|
||||||
|
"IsAuthenticated": true,
|
||||||
|
"Availability": availability,
|
||||||
|
"UserName": username,
|
||||||
|
"Role": role,
|
||||||
|
"ActiveSection": "schedule",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or update schedule
|
||||||
|
func VolunteerPostScheduleHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Redirect(w, r, "/volunteer/schedule", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := models.GetCurrentUserID(w, r)
|
||||||
|
day := r.FormValue("day")
|
||||||
|
start := r.FormValue("start_time")
|
||||||
|
end := r.FormValue("end_time")
|
||||||
|
|
||||||
|
if day == "" || start == "" || end == "" {
|
||||||
|
http.Error(w, "All fields required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := models.DB.Exec(`
|
||||||
|
INSERT INTO availability (user_id, day_of_week, start_time, end_time, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
ON CONFLICT (user_id, day_of_week)
|
||||||
|
DO UPDATE SET start_time = $3, end_time = $4`,
|
||||||
|
userID, day, start, end)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Insert availability error:", err)
|
||||||
|
http.Error(w, "Could not save schedule", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/volunteer/schedule", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete schedule
|
||||||
|
func VolunteerDeleteScheduleHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := models.GetCurrentUserID(w, r)
|
||||||
|
idStr := r.FormValue("id")
|
||||||
|
|
||||||
|
_, err := models.DB.Exec(`DELETE FROM availability WHERE availability_id = $1 AND user_id = $2`, idStr, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Delete availability error:", err)
|
||||||
|
http.Error(w, "Could not delete schedule", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/volunteer/schedule", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|||||||
10
app/internal/models/email_updates.go
Normal file
10
app/internal/models/email_updates.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func EmailMessage(msg string){
|
||||||
|
fmt.Print("Message is not sent (func not implmented) %s", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -9,49 +9,49 @@ import (
|
|||||||
|
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID int
|
UserID int
|
||||||
Role int
|
Role int
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenResponse struct {
|
type TokenResponse struct {
|
||||||
Token string
|
Token string
|
||||||
User User
|
User User
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrorResponse struct {
|
type ErrorResponse struct {
|
||||||
Error string
|
Error string
|
||||||
Details []string
|
Details []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Role struct {
|
type Role struct {
|
||||||
RoleID int
|
RoleID int
|
||||||
Name string
|
Name string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
UserID int
|
UserID int
|
||||||
FirstName string
|
FirstName string
|
||||||
LastName string
|
LastName string
|
||||||
Email string
|
Email string
|
||||||
Phone string
|
Phone string
|
||||||
Password string
|
Password string
|
||||||
RoleID int
|
RoleID int
|
||||||
AdminCode *string
|
AdminCode *string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserAddress struct {
|
type UserAddress struct {
|
||||||
UserID int
|
UserID int
|
||||||
AddressLine1 string
|
AddressLine1 string
|
||||||
AddressLine2 string
|
AddressLine2 string
|
||||||
City string
|
City string
|
||||||
Province string
|
Province string
|
||||||
Country string
|
Country string
|
||||||
PostalCode string
|
PostalCode string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -61,18 +61,18 @@ type UserAddress struct {
|
|||||||
// =====================
|
// =====================
|
||||||
|
|
||||||
type AddressDatabase struct {
|
type AddressDatabase struct {
|
||||||
AddressID int
|
AddressID int
|
||||||
Address string
|
Address string
|
||||||
StreetName string
|
StreetName string
|
||||||
StreetType string
|
StreetType string
|
||||||
StreetQuadrant string
|
StreetQuadrant string
|
||||||
HouseNumber string
|
HouseNumber string
|
||||||
HouseAlpha *string
|
HouseAlpha *string
|
||||||
Longitude float64
|
Longitude float64
|
||||||
Latitude float64
|
Latitude float64
|
||||||
VisitedValidated bool
|
VisitedValidated bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
Assigned bool // <-- add this
|
Assigned bool // <-- add this
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -83,29 +83,29 @@ type AddressDatabase struct {
|
|||||||
|
|
||||||
|
|
||||||
type Team struct {
|
type Team struct {
|
||||||
TeamID int
|
TeamID int
|
||||||
TeamLeadID int
|
TeamLeadID int
|
||||||
VolunteerID int
|
VolunteerID int
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminVolunteer struct {
|
type AdminVolunteer struct {
|
||||||
AdminID int
|
AdminID int
|
||||||
VolunteerID int
|
VolunteerID int
|
||||||
IsActive bool
|
IsActive bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Appointment struct {
|
type Appointment struct {
|
||||||
SchedID int
|
SchedID int
|
||||||
UserID int
|
UserID int
|
||||||
AddressID int
|
AddressID int
|
||||||
AppointmentDate time.Time
|
AppointmentDate time.Time
|
||||||
AppointmentTime time.Time
|
AppointmentTime time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
@@ -113,22 +113,22 @@ type Appointment struct {
|
|||||||
// =====================
|
// =====================
|
||||||
|
|
||||||
type Poll struct {
|
type Poll struct {
|
||||||
PollID int
|
PollID int
|
||||||
AddressID int
|
AddressID int
|
||||||
UserID int
|
UserID int
|
||||||
ResponseURL string
|
ResponseURL string
|
||||||
AmountDonated float64
|
AmountDonated float64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type PollResponse struct {
|
type PollResponse struct {
|
||||||
ResponseID int
|
ResponseID int
|
||||||
PollID int
|
PollID int
|
||||||
Signage bool
|
Signage bool
|
||||||
VotingChoice string
|
VotingChoice string
|
||||||
DonationAmount float64
|
DonationAmount float64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
@@ -146,11 +146,11 @@ type Post struct {
|
|||||||
|
|
||||||
|
|
||||||
type Reaction struct {
|
type Reaction struct {
|
||||||
ReactionID int
|
ReactionID int
|
||||||
PostID int
|
PostID int
|
||||||
UserID int
|
UserID int
|
||||||
ReactionType string
|
ReactionType string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
@@ -159,11 +159,11 @@ type Reaction struct {
|
|||||||
|
|
||||||
type Availability struct {
|
type Availability struct {
|
||||||
AvailabilityID int
|
AvailabilityID int
|
||||||
UserID int
|
UserID int
|
||||||
DayOfWeek string
|
DayOfWeek string
|
||||||
StartTime time.Time
|
StartTime time.Time
|
||||||
EndTime time.Time
|
EndTime time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
@@ -171,10 +171,10 @@ type Availability struct {
|
|||||||
// =====================
|
// =====================
|
||||||
|
|
||||||
type ChatLink struct {
|
type ChatLink struct {
|
||||||
ChatID int
|
ChatID int
|
||||||
Platform string
|
Platform string
|
||||||
URL string
|
URL string
|
||||||
UserID *int
|
UserID *int
|
||||||
TeamID *int
|
TeamID *int
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,185 @@
|
|||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<div class="flex-1 flex flex-col overflow-hidden">
|
<div class="flex-1 flex flex-col overflow-hidden">
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="bg-gray-50 border-b border-gray-200 px-6 py-3">
|
<div class="bg-white border-b border-gray-200 px-4 md:px-6 py-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<div class="flex items-center gap-4 text-sm">
|
<!-- Search -->
|
||||||
<div class="relative">
|
<div class="relative w-full sm:w-auto">
|
||||||
<i
|
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"></i>
|
||||||
class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"
|
<input
|
||||||
></i>
|
type="text"
|
||||||
<input
|
placeholder="Search appointments..."
|
||||||
type="text"
|
x-model="search"
|
||||||
placeholder="Search Appointments"
|
class="w-full sm:w-80 pl-10 pr-4 py-2 text-sm border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
class="w-full pl-8 pr-3 py-2 text-sm border border-gray-200 rounded bg-white"
|
/>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{{ if .Pagination }}
|
||||||
|
<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">
|
||||||
|
<label for="pageSize" class="text-sm text-gray-600 whitespace-nowrap">Per page:</label>
|
||||||
|
<select
|
||||||
|
id="pageSize"
|
||||||
|
onchange="changePageSize(this.value)"
|
||||||
|
class="px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="20" {{if eq .Pagination.PageSize 20}}selected{{end}}>20</option>
|
||||||
|
<option value="50" {{if eq .Pagination.PageSize 50}}selected{{end}}>50</option>
|
||||||
|
<option value="100" {{if eq .Pagination.PageSize 100}}selected{{end}}>100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick="goToPage({{.Pagination.PreviousPage}})"
|
||||||
|
{{if not .Pagination.HasPrevious}}disabled{{end}}
|
||||||
|
class="px-3 py-2 text-sm border border-gray-200 rounded-lg {{if .Pagination.HasPrevious}}hover:bg-gray-50 text-gray-700{{else}}text-gray-400 cursor-not-allowed{{end}} transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<span class="px-3 py-2 text-sm text-gray-600 whitespace-nowrap">
|
||||||
|
{{.Pagination.CurrentPage}} / {{.Pagination.TotalPages}}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick="goToPage({{.Pagination.NextPage}})"
|
||||||
|
{{if not .Pagination.HasNext}}disabled{{end}}
|
||||||
|
class="px-3 py-2 text-sm border border-gray-200 rounded-lg {{if .Pagination.HasNext}}hover:bg-gray-50 text-gray-700{{else}}text-gray-400 cursor-not-allowed{{end}} transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table Container -->
|
||||||
|
<div class="flex-1 p-4 md:p-6 overflow-auto" x-data="{ search: '' }">
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Desktop Table -->
|
||||||
|
<div class="hidden lg:block overflow-x-auto">
|
||||||
|
<table class="w-full min-w-full">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Poll</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Address</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Appointment</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-100">
|
||||||
|
{{ range .Appointments }}
|
||||||
|
<tr
|
||||||
|
class="hover:bg-gray-50"
|
||||||
|
x-show="
|
||||||
|
'{{ .Address }} {{ .PollButtonText }} {{ .AppointmentDate.Format "2006-01-02" }}'
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search.toLowerCase())
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- Poll -->
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{{ if .HasPollResponse }}
|
||||||
|
<span class="{{ .PollButtonClass }}">{{ .PollButtonText }}</span>
|
||||||
|
{{ else }}
|
||||||
|
<a href="/poll?address_id={{ .AddressID }}" class="{{ .PollButtonClass }}">
|
||||||
|
{{ .PollButtonText }}
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<a
|
||||||
|
href="https://www.google.com/maps/search/?api=1&query={{ .Latitude }},{{ .Longitude }}"
|
||||||
|
target="_blank"
|
||||||
|
class="text-blue-600 hover:text-blue-800 text-sm hover:underline"
|
||||||
|
>
|
||||||
|
{{ .Address }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Appointment -->
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
|
({{ .AppointmentDate.Format "2006-01-02" }} @ {{ .AppointmentTime.Format "15:04" }})
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="px-6 py-8 text-center text-gray-500">No appointments found</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Cards -->
|
||||||
|
<div class="lg:hidden">
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
{{ range .Appointments }}
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<!-- Card Header -->
|
||||||
|
<div class="bg-gray-50 px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<span class="text-sm font-semibold text-gray-900">Appointment</span>
|
||||||
|
{{ if .HasPollResponse }}
|
||||||
|
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||||
|
{{ .PollButtonText }}
|
||||||
|
</span>
|
||||||
|
{{ else }}
|
||||||
|
<a
|
||||||
|
href="/poll?address_id={{ .AddressID }}"
|
||||||
|
class="inline-flex items-center px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 rounded-full"
|
||||||
|
>
|
||||||
|
{{ .PollButtonText }}
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Content -->
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm font-medium text-gray-900">{{ .Address }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-gray-500">Date</span>
|
||||||
|
<span class="text-sm text-gray-900">{{ .AppointmentDate.Format "2006-01-02" }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-gray-500">Time</span>
|
||||||
|
<span class="text-sm text-gray-900">{{ .AppointmentTime.Format "15:04" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="text-gray-400 mb-4">
|
||||||
|
<i class="fas fa-calendar-times text-4xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No appointments found</h3>
|
||||||
|
<p class="text-gray-500">Try adjusting your search criteria.</p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
|
||||||
<div
|
|
||||||
class="flex-1 overflow-x-auto overflow-y-auto bg-white border border-gray-100"
|
|
||||||
>
|
|
||||||
<table class="w-full divide-gray-200 text-sm table-auto">
|
|
||||||
<thead class="bg-gray-50 divide-gray-200 sticky top-0">
|
|
||||||
<tr
|
|
||||||
class="text-left text-gray-700 font-medium border-b border-gray-200"
|
|
||||||
>
|
|
||||||
<th class="px-6 py-3 whitespace-nowrap">Poll</th>
|
|
||||||
<th class="px-6 py-3 whitespace-nowrap">Address</th>
|
|
||||||
<th class="px-6 py-3 whitespace-nowrap">Appointment</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200">
|
|
||||||
{{ range .Appointments }}
|
|
||||||
<tr class="hover:bg-gray-50">
|
|
||||||
<td class="px-6 py-3 whitespace-nowrap">
|
|
||||||
{{ if .HasPollResponse }}
|
|
||||||
<span class="{{ .PollButtonClass }}"> {{ .PollButtonText }} </span>
|
|
||||||
{{ else }}
|
|
||||||
<a
|
|
||||||
href="/poll?address_id={{ .AddressID }}"
|
|
||||||
class="{{ .PollButtonClass }}"
|
|
||||||
>
|
|
||||||
{{ .PollButtonText }}
|
|
||||||
</a>
|
|
||||||
{{ end }}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3 whitespace-nowrap">
|
|
||||||
<a
|
|
||||||
href="https://www.google.com/maps/search/?api=1&query={{ .Latitude }},{{ .Longitude }}"
|
|
||||||
target="_blank"
|
|
||||||
class="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
{{ .Address }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3 whitespace-nowrap">
|
|
||||||
({{ .AppointmentDate.Format "2006-01-02" }} @ {{
|
|
||||||
.AppointmentTime.Format "15:04" }})
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{ else }}
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="px-6 py-8 text-center text-gray-500">
|
|
||||||
No appointments found
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{ end }}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function goToPage(page) {
|
||||||
|
var urlParams = new URLSearchParams(window.location.search);
|
||||||
|
urlParams.set("page", page);
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePageSize(pageSize) {
|
||||||
|
var urlParams = new URLSearchParams(window.location.search);
|
||||||
|
urlParams.set("pageSize", pageSize);
|
||||||
|
urlParams.set("page", 1);
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -161,10 +161,14 @@
|
|||||||
<i class="fas fa-chart-pie w-5 {{if eq .ActiveSection "dashboard"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
<i class="fas fa-chart-pie w-5 {{if eq .ActiveSection "dashboard"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||||
<span {{if eq .ActiveSection "dashboard"}}class="font-medium"{{end}}>Dashboard</span>
|
<span {{if eq .ActiveSection "dashboard"}}class="font-medium"{{end}}>Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/volunteer/Addresses" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "address"}}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">
|
<a href="/volunteer/addresses" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "address"}}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-home w-5 {{if eq .ActiveSection "address"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
<i class="fas fa-home w-5 {{if eq .ActiveSection "address"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||||
<span {{if eq .ActiveSection "address"}}class="font-medium"{{end}}>Assigned Address</span>
|
<span {{if eq .ActiveSection "address"}}class="font-medium"{{end}}>Assigned Address</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/volunteer/schedule" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "schedule"}}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-calendar-day w-5 {{if eq .ActiveSection "schedule"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||||
|
<span {{if eq .ActiveSection "schedule"}}class="font-medium"{{end}}>Schedule</span>
|
||||||
|
</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<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">
|
||||||
|
|||||||
@@ -1,338 +1,344 @@
|
|||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<div class="min-h-screen bg-gray-100">
|
<div class="min-h-screen bg-gray-100">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
<div class="max-w-2xl mx-auto">
|
<!-- Create Post Form -->
|
||||||
<!-- Create Post Form -->
|
<div class="bg-white border-b border-gray-200 p-6">
|
||||||
<div class="bg-white border-b border-gray-200 p-6">
|
<form
|
||||||
<form
|
action="/posts"
|
||||||
action="/posts"
|
method="POST"
|
||||||
method="POST"
|
enctype="multipart/form-data"
|
||||||
enctype="multipart/form-data"
|
class="space-y-4"
|
||||||
class="space-y-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-start space-x-4">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div
|
|
||||||
class="w-10 h-10 bg-blue-500 flex items-center justify-center text-white font-semibold"
|
|
||||||
>
|
>
|
||||||
You
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-blue-500 flex items-center justify-center text-white font-semibold"
|
||||||
|
>
|
||||||
|
You
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<textarea
|
||||||
|
id="content"
|
||||||
|
name="content"
|
||||||
|
placeholder="What's on your mind?"
|
||||||
|
class="w-full px-0 py-2 text-gray-900 placeholder-gray-500 border-0 resize-none focus:outline-none focus:ring-0"
|
||||||
|
rows="3"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between pt-4 border-t border-gray-100"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<label
|
||||||
|
for="image"
|
||||||
|
class="cursor-pointer flex items-center space-x-2 text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium">Photo</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="image"
|
||||||
|
type="file"
|
||||||
|
name="image"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 text-sm font-semibold transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Post
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image preview -->
|
||||||
|
<div id="imagePreview" class="hidden">
|
||||||
|
<img
|
||||||
|
id="previewImg"
|
||||||
|
class="w-full h-64 object-cover border"
|
||||||
|
alt="Preview"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="removeImage"
|
||||||
|
class="mt-2 text-red-500 text-sm hover:text-red-600"
|
||||||
|
>
|
||||||
|
Remove image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Posts Feed -->
|
||||||
|
<div class="space-y-0">
|
||||||
|
{{range .Posts}}
|
||||||
|
<article class="bg-white border-b border-gray-200">
|
||||||
|
<!-- Post Header -->
|
||||||
|
<div class="flex items-center px-6 py-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-blue-500 flex items-center justify-center text-white font-semibold"
|
||||||
|
>
|
||||||
|
{{slice .AuthorName 0 1}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-semibold text-gray-900">
|
||||||
|
{{.AuthorName}}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
{{.CreatedAt.Format "Jan 2, 2006"}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Image -->
|
||||||
|
{{if .ImageURL}}
|
||||||
|
<div class="w-full">
|
||||||
|
<img
|
||||||
|
src="{{.ImageURL}}"
|
||||||
|
alt="Post image"
|
||||||
|
class="w-full max-h-96 object-cover"
|
||||||
|
onerror="this.parentElement.style.display='none'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Post Content -->
|
||||||
|
{{if .Content}}
|
||||||
|
<div class="px-6 pt-2 pb-4">
|
||||||
|
<p class="text-gray-900 leading-relaxed">
|
||||||
|
<span class="font-semibold">{{.AuthorName}}</span>
|
||||||
|
{{.Content}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
{{else}}
|
||||||
|
<div class="bg-white p-12 text-center">
|
||||||
|
<div class="max-w-sm mx-auto">
|
||||||
|
<svg
|
||||||
|
class="w-16 h-16 mx-auto text-gray-300 mb-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
No posts yet
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-500">
|
||||||
|
Be the first to share something with the community!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{{end}}
|
||||||
<div class="flex-1">
|
|
||||||
<textarea
|
|
||||||
id="content"
|
|
||||||
name="content"
|
|
||||||
placeholder="What's on your mind?"
|
|
||||||
class="w-full px-0 py-2 text-gray-900 placeholder-gray-500 border-0 resize-none focus:outline-none focus:ring-0"
|
|
||||||
rows="3"
|
|
||||||
required
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between pt-4 border-t border-gray-100"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<label
|
|
||||||
for="image"
|
|
||||||
class="cursor-pointer flex items-center space-x-2 text-blue-500 hover:text-blue-600"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-6 h-6"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm font-medium">Photo</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="image"
|
|
||||||
type="file"
|
|
||||||
name="image"
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 text-sm font-semibold transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Post
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Image preview -->
|
|
||||||
<div id="imagePreview" class="hidden">
|
|
||||||
<img
|
|
||||||
id="previewImg"
|
|
||||||
class="w-full h-64 object-cover border"
|
|
||||||
alt="Preview"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="removeImage"
|
|
||||||
class="mt-2 text-red-500 text-sm hover:text-red-600"
|
|
||||||
>
|
|
||||||
Remove image
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Posts Feed -->
|
|
||||||
<div class="space-y-0">
|
|
||||||
{{range .Posts}}
|
|
||||||
<article class="bg-white border-b border-gray-200">
|
|
||||||
<!-- Post Header -->
|
|
||||||
<div class="flex items-center px-6 py-4">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div
|
|
||||||
class="w-10 h-10 bg-blue-500 flex items-center justify-center text-white font-semibold"
|
|
||||||
>
|
|
||||||
{{slice .AuthorName 0 1}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-semibold text-gray-900">{{.AuthorName}}</p>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
{{.CreatedAt.Format "Jan 2, 2006"}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Post Image -->
|
|
||||||
{{if .ImageURL}}
|
|
||||||
<div class="w-full">
|
|
||||||
<img
|
|
||||||
src="{{.ImageURL}}"
|
|
||||||
alt="Post image"
|
|
||||||
class="w-full max-h-96 object-cover"
|
|
||||||
onerror="this.parentElement.style.display='none'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<!-- Post Content -->
|
|
||||||
{{if .Content}}
|
|
||||||
<div class="px-6 pt-2 pb-4">
|
|
||||||
<p class="text-gray-900 leading-relaxed">
|
|
||||||
<span class="font-semibold">{{.AuthorName}}</span> {{.Content}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</article>
|
|
||||||
{{else}}
|
|
||||||
<div class="bg-white p-12 text-center">
|
|
||||||
<div class="max-w-sm mx-auto">
|
|
||||||
<svg
|
|
||||||
class="w-16 h-16 mx-auto text-gray-300 mb-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts yet</h3>
|
|
||||||
<p class="text-gray-500">
|
|
||||||
Be the first to share something with the community!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Custom styles for Instagram-like feel */
|
/* Custom styles for Instagram-like feel */
|
||||||
.reaction-btn.active {
|
.reaction-btn.active {
|
||||||
color: #3b82f6 !important;
|
color: #3b82f6 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-btn.active svg {
|
.reaction-btn.active svg {
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-btn.dislike-active {
|
.reaction-btn.dislike-active {
|
||||||
color: #ef4444 !important;
|
color: #ef4444 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth transitions */
|
/* Smooth transitions */
|
||||||
.reaction-btn {
|
.reaction-btn {
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-btn:hover {
|
.reaction-btn:hover {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus styles */
|
/* Focus styles */
|
||||||
button:focus {
|
button:focus {
|
||||||
outline: 2px solid #3b82f6;
|
outline: 2px solid #3b82f6;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: 2px solid #3b82f6;
|
outline: 2px solid #3b82f6;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const fileInput = document.getElementById("image");
|
const fileInput = document.getElementById("image");
|
||||||
const imagePreview = document.getElementById("imagePreview");
|
const imagePreview = document.getElementById("imagePreview");
|
||||||
const previewImg = document.getElementById("previewImg");
|
const previewImg = document.getElementById("previewImg");
|
||||||
const removeImageBtn = document.getElementById("removeImage");
|
const removeImageBtn = document.getElementById("removeImage");
|
||||||
const form = document.querySelector("form");
|
const form = document.querySelector("form");
|
||||||
|
|
||||||
// Image upload preview
|
// Image upload preview
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
fileInput.addEventListener("change", function (e) {
|
fileInput.addEventListener("change", function (e) {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
console.log(
|
console.log(
|
||||||
"Selected file:",
|
"Selected file:",
|
||||||
file.name,
|
file.name,
|
||||||
"Size:",
|
"Size:",
|
||||||
file.size,
|
file.size,
|
||||||
"Type:",
|
"Type:",
|
||||||
file.type
|
file.type,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate file size (10MB max)
|
// Validate file size (10MB max)
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
alert("File is too large. Maximum size is 10MB.");
|
alert("File is too large. Maximum size is 10MB.");
|
||||||
this.value = "";
|
this.value = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file type
|
// Validate file type
|
||||||
const allowedTypes = [
|
const allowedTypes = [
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
"image/jpg",
|
"image/jpg",
|
||||||
"image/png",
|
"image/png",
|
||||||
"image/gif",
|
"image/gif",
|
||||||
"image/webp",
|
"image/webp",
|
||||||
];
|
];
|
||||||
if (!allowedTypes.includes(file.type)) {
|
if (!allowedTypes.includes(file.type)) {
|
||||||
alert("Invalid file type. Please select a valid image file.");
|
alert(
|
||||||
this.value = "";
|
"Invalid file type. Please select a valid image file.",
|
||||||
return;
|
);
|
||||||
}
|
this.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Show preview
|
// Show preview
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = function (e) {
|
reader.onload = function (e) {
|
||||||
previewImg.src = e.target.result;
|
previewImg.src = e.target.result;
|
||||||
imagePreview.classList.remove("hidden");
|
imagePreview.classList.remove("hidden");
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Remove image preview
|
|
||||||
if (removeImageBtn) {
|
|
||||||
removeImageBtn.addEventListener("click", function () {
|
|
||||||
fileInput.value = "";
|
|
||||||
imagePreview.classList.add("hidden");
|
|
||||||
previewImg.src = "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reaction buttons
|
|
||||||
const reactionBtns = document.querySelectorAll(".reaction-btn");
|
|
||||||
reactionBtns.forEach(function (btn) {
|
|
||||||
btn.addEventListener("click", function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const postId = this.dataset.postId;
|
|
||||||
const reaction = this.dataset.reaction;
|
|
||||||
|
|
||||||
// Toggle active state
|
|
||||||
if (reaction === "like") {
|
|
||||||
this.classList.toggle("active");
|
|
||||||
// Remove dislike active state from sibling
|
|
||||||
const dislikeBtn = this.parentElement.querySelector(
|
|
||||||
'[data-reaction="dislike"]'
|
|
||||||
);
|
|
||||||
dislikeBtn.classList.remove("dislike-active");
|
|
||||||
} else {
|
|
||||||
this.classList.toggle("dislike-active");
|
|
||||||
// Remove like active state from sibling
|
|
||||||
const likeBtn = this.parentElement.querySelector(
|
|
||||||
'[data-reaction="like"]'
|
|
||||||
);
|
|
||||||
likeBtn.classList.remove("active");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update count (mock implementation)
|
// Remove image preview
|
||||||
const countSpan = this.querySelector("span");
|
if (removeImageBtn) {
|
||||||
const currentCount = parseInt(countSpan.textContent);
|
removeImageBtn.addEventListener("click", function () {
|
||||||
const isActive =
|
fileInput.value = "";
|
||||||
this.classList.contains("active") ||
|
imagePreview.classList.add("hidden");
|
||||||
this.classList.contains("dislike-active");
|
previewImg.src = "";
|
||||||
countSpan.textContent = isActive
|
});
|
||||||
? currentCount + 1
|
}
|
||||||
: Math.max(0, currentCount - 1);
|
|
||||||
|
|
||||||
console.log(`${reaction} clicked for post ${postId}`);
|
// Reaction buttons
|
||||||
|
const reactionBtns = document.querySelectorAll(".reaction-btn");
|
||||||
|
reactionBtns.forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const postId = this.dataset.postId;
|
||||||
|
const reaction = this.dataset.reaction;
|
||||||
|
|
||||||
// Here you would typically send an AJAX request to update the backend
|
// Toggle active state
|
||||||
// fetch(`/posts/${postId}/react`, {
|
if (reaction === "like") {
|
||||||
// method: 'POST',
|
this.classList.toggle("active");
|
||||||
// headers: { 'Content-Type': 'application/json' },
|
// Remove dislike active state from sibling
|
||||||
// body: JSON.stringify({ reaction: reaction })
|
const dislikeBtn = this.parentElement.querySelector(
|
||||||
// });
|
'[data-reaction="dislike"]',
|
||||||
});
|
);
|
||||||
|
dislikeBtn.classList.remove("dislike-active");
|
||||||
|
} else {
|
||||||
|
this.classList.toggle("dislike-active");
|
||||||
|
// Remove like active state from sibling
|
||||||
|
const likeBtn = this.parentElement.querySelector(
|
||||||
|
'[data-reaction="like"]',
|
||||||
|
);
|
||||||
|
likeBtn.classList.remove("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update count (mock implementation)
|
||||||
|
const countSpan = this.querySelector("span");
|
||||||
|
const currentCount = parseInt(countSpan.textContent);
|
||||||
|
const isActive =
|
||||||
|
this.classList.contains("active") ||
|
||||||
|
this.classList.contains("dislike-active");
|
||||||
|
countSpan.textContent = isActive
|
||||||
|
? currentCount + 1
|
||||||
|
: Math.max(0, currentCount - 1);
|
||||||
|
|
||||||
|
console.log(`${reaction} clicked for post ${postId}`);
|
||||||
|
|
||||||
|
// Here you would typically send an AJAX request to update the backend
|
||||||
|
// fetch(`/posts/${postId}/react`, {
|
||||||
|
// method: 'POST',
|
||||||
|
// headers: { 'Content-Type': 'application/json' },
|
||||||
|
// body: JSON.stringify({ reaction: reaction })
|
||||||
|
// });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
const content = document.getElementById("content").value.trim();
|
||||||
|
const hasImage = fileInput.files.length > 0;
|
||||||
|
|
||||||
|
if (!content && !hasImage) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert("Please add some content or an image to your post.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Form being submitted...");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
const textarea = document.getElementById("content");
|
||||||
|
if (textarea) {
|
||||||
|
textarea.addEventListener("input", function () {
|
||||||
|
this.style.height = "auto";
|
||||||
|
this.style.height = this.scrollHeight + "px";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth scroll to top when clicking header
|
||||||
|
const header = document.querySelector("h1");
|
||||||
|
if (header) {
|
||||||
|
header.addEventListener("click", function () {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form submission
|
|
||||||
if (form) {
|
|
||||||
form.addEventListener("submit", function (e) {
|
|
||||||
const content = document.getElementById("content").value.trim();
|
|
||||||
const hasImage = fileInput.files.length > 0;
|
|
||||||
|
|
||||||
if (!content && !hasImage) {
|
|
||||||
e.preventDefault();
|
|
||||||
alert("Please add some content or an image to your post.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Form being submitted...");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-resize textarea
|
|
||||||
const textarea = document.getElementById("content");
|
|
||||||
if (textarea) {
|
|
||||||
textarea.addEventListener("input", function () {
|
|
||||||
this.style.height = "auto";
|
|
||||||
this.style.height = this.scrollHeight + "px";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Smooth scroll to top when clicking header
|
|
||||||
const header = document.querySelector("h1");
|
|
||||||
if (header) {
|
|
||||||
header.addEventListener("click", function () {
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -1,149 +1,241 @@
|
|||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<div class="flex-1 flex flex-col overflow-hidden">
|
<div class="flex-1 flex flex-col overflow-hidden" x-data="reportsData()">
|
||||||
<!-- Toolbar with Report Selection -->
|
<!-- Toolbar -->
|
||||||
<div class="bg-gray-50 border-b border-gray-200 px-6 py-3">
|
<div class="bg-white border-b border-gray-200 px-4 md:px-6 py-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
|
||||||
<div class="flex items-center gap-4 text-sm">
|
<!-- Report Selection Form -->
|
||||||
<form method="GET" action="/reports" class="flex items-center gap-3">
|
<form method="GET" action="/reports" class="flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full lg:w-auto">
|
||||||
<!-- Category Dropdown -->
|
<!-- Category Selection -->
|
||||||
<div class="relative">
|
<div class="flex items-center gap-2">
|
||||||
<label for="category" class="text-gray-700 font-medium mr-2">Category:</label>
|
<label for="category" class="text-sm text-gray-600 whitespace-nowrap font-medium">Category:</label>
|
||||||
<select
|
<select
|
||||||
name="category"
|
name="category"
|
||||||
id="category"
|
id="category"
|
||||||
onchange="updateReports()"
|
onchange="updateReports()"
|
||||||
class="px-3 py-2 text-sm border border-gray-200 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500 min-w-48"
|
class="px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-48"
|
||||||
>
|
>
|
||||||
<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="addresses" {{if eq .Category "addresses"}}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>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Report Dropdown -->
|
|
||||||
<div class="relative">
|
|
||||||
<label for="report" class="text-gray-700 font-medium mr-2">Report:</label>
|
|
||||||
<select
|
|
||||||
name="report"
|
|
||||||
id="report"
|
|
||||||
class="px-3 py-2 text-sm border border-gray-200 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500 min-w-64"
|
|
||||||
>
|
|
||||||
<option value="">Select Report</option>
|
|
||||||
{{if .Category}}
|
|
||||||
{{range .AvailableReports}}
|
|
||||||
<option value="{{.ID}}" {{if eq .ID $.ReportID}}selected{{end}}>{{.Name}}</option>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Date Range -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<label for="date_from" class="text-gray-700 font-medium">From:</label>
|
|
||||||
<input type="date" name="date_from" id="date_from" value="{{.DateFrom}}" class="px-3 py-2 text-sm border border-gray-200 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500"/>
|
|
||||||
<label for="date_to" class="text-gray-700 font-medium">To:</label>
|
|
||||||
<input type="date" name="date_to" id="date_to" value="{{.DateTo}}" class="px-3 py-2 text-sm border border-gray-200 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="px-4 py-2 bg-purple-600 text-white font-medium hover:bg-purple-700 transition-all duration-200 text-sm">
|
|
||||||
<i class="fas fa-chart-bar mr-2"></i>Generate Report
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
{{if .Result}}
|
|
||||||
<div class="flex items-center gap-3 text-sm">
|
|
||||||
<div class="text-gray-600">
|
|
||||||
<span>{{.Result.Count}} results</span>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- <button onclick="exportResults()" class="px-3 py-1.5 bg-green-600 text-white hover:bg-green-700 transition-colors">
|
|
||||||
<i class="fas fa-download mr-1"></i>Export CSV
|
<!-- Report Selection -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="report" class="text-sm text-gray-600 whitespace-nowrap font-medium">Report:</label>
|
||||||
|
<select
|
||||||
|
name="report"
|
||||||
|
id="report"
|
||||||
|
class="px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-64"
|
||||||
|
>
|
||||||
|
<option value="">Select Report</option>
|
||||||
|
{{if .Category}}
|
||||||
|
{{range .AvailableReports}}
|
||||||
|
<option value="{{.ID}}" {{if eq .ID $.ReportID}}selected{{end}}>{{.Name}}</option>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Range -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="date_from" class="text-sm text-gray-600 whitespace-nowrap font-medium">From:</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="date_from"
|
||||||
|
id="date_from"
|
||||||
|
value="{{.DateFrom}}"
|
||||||
|
class="px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="date_to" class="text-sm text-gray-600 whitespace-nowrap font-medium">To:</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="date_to"
|
||||||
|
id="date_to"
|
||||||
|
value="{{.DateTo}}"
|
||||||
|
class="px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generate Button -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-6 py-2.5 bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors rounded-lg"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chart-bar mr-2"></i>Generate Report
|
||||||
</button>
|
</button>
|
||||||
<button onclick="printReport()" class="px-3 py-1.5 bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
</form>
|
||||||
<i class="fas fa-print mr-1"></i>Print
|
|
||||||
</button> -->
|
<!-- Actions & Results Count -->
|
||||||
|
{{if .Result}}
|
||||||
|
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
<span class="font-medium">{{.Result.Count}}</span> results
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="flex-1 p-4 md:p-6 overflow-auto">
|
||||||
{{if .Result}}
|
{{if .Result}}
|
||||||
{{if .Result.Error}}
|
{{if .Result.Error}}
|
||||||
<div class="p-6">
|
<!-- Error State -->
|
||||||
<div class="bg-red-50 border border-red-200 p-6">
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
<h3 class="text-lg font-semibold text-red-800 mb-2">Report Error</h3>
|
<div class="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||||
<p class="text-red-700">{{.Result.Error}}</p>
|
<div class="flex items-center space-x-3">
|
||||||
|
<i class="fas fa-exclamation-triangle text-red-500 text-xl"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-red-800 mb-2">Report Error</h3>
|
||||||
|
<p class="text-red-700">{{.Result.Error}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<!-- Report Header -->
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
<!-- Report Header -->
|
||||||
<div>
|
<div class="bg-gray-50 border-b border-gray-200 px-6 py-4">
|
||||||
<h2 class="text-xl font-semibold text-gray-900">{{.ReportTitle}}</h2>
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<p class="text-sm text-gray-600 mt-1">{{.ReportDescription}}</p>
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">{{.ReportTitle}}</h2>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">{{.ReportDescription}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<i class="fas fa-clock mr-1"></i>Generated: {{.GeneratedAt}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-500">Generated: {{.GeneratedAt}}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Results Table -->
|
{{if gt .Result.Count 0}}
|
||||||
{{if gt .Result.Count 0}}
|
<!-- Desktop Table -->
|
||||||
<div class="flex-1 overflow-x-auto overflow-y-auto bg-white">
|
<div class="hidden lg:block overflow-x-auto">
|
||||||
<table class="w-full divide-gray-200 text-sm table-auto">
|
<table class="w-full min-w-full">
|
||||||
<thead class="bg-gray-50 sticky top-0">
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
<tr class="text-left text-gray-700 font-medium border-b border-gray-200">
|
<tr>
|
||||||
{{range .Result.Columns}}
|
{{range .Result.Columns}}
|
||||||
<th class="px-6 py-3 whitespace-nowrap">{{formatColumnName .}}</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{{end}}
|
{{formatColumnName .}}
|
||||||
</tr>
|
</th>
|
||||||
</thead>
|
{{end}}
|
||||||
<tbody class="divide-y divide-gray-200">
|
</tr>
|
||||||
{{range .Result.Rows}}
|
</thead>
|
||||||
<tr class="hover:bg-gray-50">
|
<tbody class="bg-white divide-y divide-gray-100">
|
||||||
{{range .}}
|
{{range .Result.Rows}}
|
||||||
<td class="px-6 py-3 text-sm text-gray-900">{{.}}</td>
|
<tr class="hover:bg-gray-50">
|
||||||
{{end}}
|
{{range .}}
|
||||||
</tr>
|
<td class="px-6 py-4 text-sm text-gray-900">{{.}}</td>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
{{end}}
|
||||||
</div>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
<!-- Mobile Cards -->
|
||||||
{{if .SummaryStats}}
|
<div class="lg:hidden">
|
||||||
<div class="bg-gray-50 border-t border-gray-200 px-6 py-4">
|
<div class="space-y-4 p-4">
|
||||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">Summary Statistics</h4>
|
{{range $rowIndex, $row := .Result.Rows}}
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||||
{{range .SummaryStats}}
|
<!-- Card Header -->
|
||||||
<div class="bg-white border border-gray-200 px-3 py-2">
|
<div class="bg-gray-50 px-4 py-3 border-b border-gray-200">
|
||||||
<div class="text-xs text-gray-500">{{.Label}}</div>
|
<div class="flex items-center space-x-2">
|
||||||
<div class="text-lg font-semibold text-gray-900">{{.Value}}</div>
|
<i class="fas fa-chart-line text-gray-400"></i>
|
||||||
|
<span class="text-sm font-semibold text-gray-900">Record {{add $rowIndex 1}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Content -->
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
{{range $colIndex, $column := $.Result.Columns}}
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-gray-500 font-medium">{{formatColumnName $column}}:</span>
|
||||||
|
<span class="text-sm text-gray-900 text-right ml-4">{{index $row $colIndex}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
{{if .SummaryStats}}
|
||||||
|
<div class="bg-gray-50 border-t border-gray-200 px-6 py-4">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-700 mb-4">
|
||||||
|
<i class="fas fa-chart-pie mr-2 text-blue-500"></i>Summary Statistics
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{{range .SummaryStats}}
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div class="text-xs text-gray-500 uppercase tracking-wide font-medium">{{.Label}}</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900 mt-1">{{.Value}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="flex-1 flex items-center justify-center">
|
<!-- No Results -->
|
||||||
<p class="text-gray-500">No results match your selected criteria</p>
|
<div class="text-center py-16">
|
||||||
|
<div class="text-gray-400 mb-4">
|
||||||
|
<i class="fas fa-chart-bar text-4xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No Results Found</h3>
|
||||||
|
<p class="text-gray-500">No results match your selected criteria. Try adjusting your filters or date range.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="flex-1 flex items-center justify-center">
|
<!-- Initial State -->
|
||||||
<p class="text-gray-600">Select a category and report to generate results</p>
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<div class="text-center py-16">
|
||||||
|
<div class="text-gray-400 mb-6">
|
||||||
|
<i class="fas fa-chart-line text-5xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-medium text-gray-900 mb-4">Generate Report</h3>
|
||||||
|
<p class="text-gray-500 mb-6 max-w-md mx-auto">
|
||||||
|
Select a category and report type above to generate comprehensive analytics and insights.
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-2xl mx-auto">
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<i class="fas fa-users text-blue-500 text-2xl mb-2"></i>
|
||||||
|
<h4 class="font-medium text-gray-900 mb-1">User Analytics</h4>
|
||||||
|
<p class="text-sm text-gray-600">Track volunteer performance and participation rates</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<i class="fas fa-map-marker-alt text-green-500 text-2xl mb-2"></i>
|
||||||
|
<h4 class="font-medium text-gray-900 mb-1">Location Insights</h4>
|
||||||
|
<p class="text-sm text-gray-600">Analyze address coverage and geographic data</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||||
|
<i class="fas fa-calendar-check text-purple-500 text-2xl mb-2"></i>
|
||||||
|
<h4 class="font-medium text-gray-900 mb-1">Schedule Reports</h4>
|
||||||
|
<p class="text-sm text-gray-600">Review appointments and availability patterns</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function reportsData() {
|
||||||
|
return {
|
||||||
|
// Any Alpine.js data can go here if needed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const reportDefinitions = {
|
const reportDefinitions = {
|
||||||
users: [
|
users: [
|
||||||
{ id: 'volunteer_participation_rate', name: 'Volunteer Participation Rate' },
|
{ id: 'volunteer_participation_rate', name: 'Volunteer Participation Rate' },
|
||||||
@@ -189,14 +281,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportResults() {
|
|
||||||
const params = new URLSearchParams(new FormData(document.querySelector('form')));
|
|
||||||
params.set('export', 'csv');
|
|
||||||
window.location.href = `/reports?${params.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function printReport() { window.print(); }
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", updateReports);
|
// Initialize reports on page load
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
updateReports();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Consistent styling */
|
||||||
|
input, select, button {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print styles */
|
||||||
|
@media print {
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gray-50 {
|
||||||
|
background-color: white !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive adjustments */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
--tw-space-x-reverse: 0;
|
||||||
|
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
||||||
|
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
{{ define "content" }}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Interactive Dashboard</title>
|
|
||||||
<link
|
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script
|
|
||||||
type="text/javascript"
|
|
||||||
src="https://www.gstatic.com/charts/loader.js"
|
|
||||||
></script>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-100">
|
|
||||||
<div class="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<!-- Dashboard Content -->
|
|
||||||
<div class="flex-1 overflow-auto">
|
|
||||||
<!-- Top Stats Cards -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<!-- Active Locations Card -->
|
|
||||||
<div
|
|
||||||
class="bg-white border-r border-b border-gray-200 p-8 hover:shadow-md transition-shadow cursor-pointer"
|
|
||||||
onclick="focusMap()"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div
|
|
||||||
class="w-14 h-14 bg-blue-100 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<i class="fas fa-map-marker-alt text-blue-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-600">
|
|
||||||
Active Locations
|
|
||||||
</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">24</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Total Visitors Card -->
|
|
||||||
<div
|
|
||||||
class="bg-white border-r border-b border-gray-200 p-8 hover:shadow-md transition-shadow cursor-pointer"
|
|
||||||
onclick="updateChart('visitors')"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div
|
|
||||||
class="w-14 h-14 bg-green-100 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<i class="fas fa-users text-green-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-600">Total Visitors</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">12,847</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Revenue Card -->
|
|
||||||
<div
|
|
||||||
class="bg-white border-r border-b border-gray-200 p-8 hover:shadow-md transition-shadow cursor-pointer"
|
|
||||||
onclick="updateChart('revenue')"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div
|
|
||||||
class="w-14 h-14 bg-purple-100 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<i class="fas fa-dollar-sign text-purple-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-600">Revenue</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">$47,392</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Conversion Rate Card -->
|
|
||||||
<div
|
|
||||||
class="bg-white border-b border-gray-200 p-8 hover:shadow-md transition-shadow cursor-pointer"
|
|
||||||
onclick="updateChart('conversion')"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div
|
|
||||||
class="w-14 h-14 bg-orange-100 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<i class="fas fa-percentage text-orange-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-600">Conversion Rate</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">3.2%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Full Width Google Map -->
|
|
||||||
<div class="bg-white border-b border-gray-200 p-8">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">
|
|
||||||
Location Analytics
|
|
||||||
</h3>
|
|
||||||
<div id="map" class="w-full h-[600px] border border-gray-200"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Performance Metrics Chart - Full Width Bottom -->
|
|
||||||
<div class="bg-white border-gray-200 p-8">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900">
|
|
||||||
Performance Metrics
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onclick="updateChart('daily')"
|
|
||||||
class="px-3 py-1 text-sm border border-gray-300 hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Daily
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick="updateChart('weekly')"
|
|
||||||
class="px-3 py-1 text-sm bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Weekly
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick="updateChart('monthly')"
|
|
||||||
class="px-3 py-1 text-sm border border-gray-300 hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Monthly
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="analytics_chart" class="w-full h-[400px]"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let map;
|
|
||||||
|
|
||||||
function focusMap() {
|
|
||||||
// Center map example
|
|
||||||
map.setCenter({ lat: 43.0896, lng: -79.0849 }); // Niagara Falls
|
|
||||||
map.setZoom(12);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initMap() {
|
|
||||||
const niagaraFalls = { lat: 43.0896, lng: -79.0849 };
|
|
||||||
|
|
||||||
map = new google.maps.Map(document.getElementById("map"), {
|
|
||||||
zoom: 12,
|
|
||||||
center: niagaraFalls,
|
|
||||||
});
|
|
||||||
|
|
||||||
new google.maps.Marker({
|
|
||||||
position: niagaraFalls,
|
|
||||||
map,
|
|
||||||
title: "Niagara Falls",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Google Charts
|
|
||||||
google.charts.load("current", { packages: ["corechart", "line"] });
|
|
||||||
google.charts.setOnLoadCallback(drawAnalyticsChart);
|
|
||||||
|
|
||||||
function drawAnalyticsChart() {
|
|
||||||
var data = new google.visualization.DataTable();
|
|
||||||
data.addColumn("string", "Time");
|
|
||||||
data.addColumn("number", "Visitors");
|
|
||||||
data.addColumn("number", "Revenue");
|
|
||||||
|
|
||||||
data.addRows([
|
|
||||||
["Jan", 4200, 32000],
|
|
||||||
["Feb", 4800, 38000],
|
|
||||||
["Mar", 5200, 42000],
|
|
||||||
["Apr", 4900, 39000],
|
|
||||||
["May", 5800, 45000],
|
|
||||||
["Jun", 6200, 48000],
|
|
||||||
]);
|
|
||||||
|
|
||||||
var options = {
|
|
||||||
title: "Performance Over Time",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
hAxis: { title: "Month" },
|
|
||||||
vAxis: { title: "Value" },
|
|
||||||
colors: ["#3B82F6", "#10B981"],
|
|
||||||
chartArea: {
|
|
||||||
left: 60,
|
|
||||||
top: 40,
|
|
||||||
width: "90%",
|
|
||||||
height: "70%",
|
|
||||||
},
|
|
||||||
legend: { position: "top", alignment: "center" },
|
|
||||||
};
|
|
||||||
|
|
||||||
var chart = new google.visualization.LineChart(
|
|
||||||
document.getElementById("analytics_chart")
|
|
||||||
);
|
|
||||||
chart.draw(data, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateChart(type) {
|
|
||||||
drawAnalyticsChart();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script
|
|
||||||
async
|
|
||||||
defer
|
|
||||||
src="https://maps.googleapis.com/maps/api/js?key=YOUR_KEY_HERE&callback=initMap"
|
|
||||||
></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{ end }}
|
|
||||||
@@ -1,285 +1,681 @@
|
|||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="flex-1 flex flex-col overflow-hidden" x-data="volunteerTable()">
|
<div class="flex-1 flex flex-col overflow-hidden" x-data="volunteerTable()">
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="bg-gray-50 border-b border-gray-200 px-6 py-3">
|
<div class="bg-white border-b border-gray-200 px-4 md:px-6 py-4">
|
||||||
<div class="flex items-center gap-4 text-sm">
|
<div
|
||||||
<!-- Search -->
|
class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"
|
||||||
<div class="flex items-center gap-2">
|
>
|
||||||
<div class="relative">
|
<!-- Search & Filters -->
|
||||||
<i
|
<div
|
||||||
class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"
|
class="flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full sm:w-auto"
|
||||||
></i>
|
>
|
||||||
<input
|
<!-- Search -->
|
||||||
type="text"
|
<div class="relative w-full sm:w-auto">
|
||||||
x-model="searchTerm"
|
<i
|
||||||
placeholder="Search volunteers..."
|
class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"
|
||||||
class="w-64 pl-8 pr-3 py-2 text-sm border border-gray-200 bg-white focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
></i>
|
||||||
/>
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="searchTerm"
|
||||||
|
placeholder="Search volunteers..."
|
||||||
|
class="w-full sm:w-80 pl-10 pr-4 py-2 text-sm border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Role Filter -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
for="roleFilter"
|
||||||
|
class="text-sm text-gray-600 whitespace-nowrap"
|
||||||
|
>Role:</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
x-model="roleFilter"
|
||||||
|
class="px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Roles</option>
|
||||||
|
<option value="1">Admin</option>
|
||||||
|
<option value="2">Team Leader</option>
|
||||||
|
<option value="3">Volunteer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clear Filters -->
|
||||||
|
<button
|
||||||
|
@click="clearFilters()"
|
||||||
|
class="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times mr-1"></i>Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions & Results -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="px-6 py-2.5 bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors rounded-lg"
|
||||||
|
onclick="openAddVolunteerPanel()"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user-plus mr-2"></i>Add Volunteer
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Results Count -->
|
||||||
|
<div class="text-sm text-gray-600 whitespace-nowrap">
|
||||||
|
Showing <span x-text="filteredVolunteers.length"></span> of
|
||||||
|
<span x-text="volunteers.length"></span> volunteers
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Role Filter -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<label for="roleFilter" class="text-gray-600 font-medium">Role:</label>
|
|
||||||
<select
|
|
||||||
x-model="roleFilter"
|
|
||||||
class="px-3 py-2 text-sm border border-gray-200 bg-white focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
|
||||||
>
|
|
||||||
<option value="">All Roles</option>
|
|
||||||
<option value="1">Admin</option>
|
|
||||||
<option value="2">Team Leader</option>
|
|
||||||
<option value="3">Volunteer</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Clear Filters -->
|
|
||||||
<button
|
|
||||||
@click="clearFilters()"
|
|
||||||
class="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times mr-1"></i>Clear
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Results Count -->
|
|
||||||
<div class="ml-auto">
|
|
||||||
<span class="text-gray-600 text-sm">
|
|
||||||
Showing <span x-text="filteredVolunteers.length"></span> of
|
|
||||||
<span x-text="volunteers.length"></span> volunteers
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table Wrapper -->
|
<!-- Table Container -->
|
||||||
<div
|
<div class="flex-1 p-4 md:p-6 overflow-auto">
|
||||||
class="flex-1 overflow-x-auto overflow-y-auto bg-white border border-gray-100"
|
<div
|
||||||
>
|
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"
|
||||||
<table class="w-full divide-gray-200 text-sm table-auto">
|
|
||||||
<!-- Table Head -->
|
|
||||||
<thead class="bg-gray-50 divide-gray-200">
|
|
||||||
<tr
|
|
||||||
class="text-left text-gray-700 font-medium border-b border-gray-200"
|
|
||||||
>
|
>
|
||||||
<th class="px-4 py-3 whitespace-nowrap">
|
<!-- Desktop Table -->
|
||||||
<div
|
<div class="hidden lg:block overflow-x-auto">
|
||||||
class="flex items-center gap-2 cursor-pointer"
|
<table class="w-full min-w-full">
|
||||||
@click="sortBy('UserID')"
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
>
|
<tr>
|
||||||
ID <i class="fas" :class="getSortIcon('UserID')"></i>
|
<th
|
||||||
</div>
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap"
|
||||||
</th>
|
>
|
||||||
<th class="px-6 py-3 whitespace-nowrap">
|
<div
|
||||||
<div
|
class="flex items-center gap-2 cursor-pointer"
|
||||||
class="flex items-center gap-2 cursor-pointer"
|
@click="sortBy('UserID')"
|
||||||
@click="sortBy('FirstName')"
|
>
|
||||||
>
|
ID
|
||||||
First Name <i class="fas" :class="getSortIcon('FirstName')"></i>
|
<i
|
||||||
</div>
|
class="fas"
|
||||||
</th>
|
:class="getSortIcon('UserID')"
|
||||||
<th class="px-6 py-3 whitespace-nowrap">
|
></i>
|
||||||
<div
|
</div>
|
||||||
class="flex items-center gap-2 cursor-pointer"
|
</th>
|
||||||
@click="sortBy('LastName')"
|
<th
|
||||||
>
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
Last Name <i class="fas" :class="getSortIcon('LastName')"></i>
|
>
|
||||||
</div>
|
<div
|
||||||
</th>
|
class="flex items-center gap-2 cursor-pointer"
|
||||||
<th class="px-6 py-3 whitespace-nowrap">
|
@click="sortBy('FirstName')"
|
||||||
<div
|
>
|
||||||
class="flex items-center gap-2 cursor-pointer"
|
Name
|
||||||
@click="sortBy('Email')"
|
<i
|
||||||
>
|
class="fas"
|
||||||
Email <i class="fas" :class="getSortIcon('Email')"></i>
|
:class="getSortIcon('FirstName')"
|
||||||
</div>
|
></i>
|
||||||
</th>
|
</div>
|
||||||
<th class="px-6 py-3 whitespace-nowrap">
|
</th>
|
||||||
<div
|
<th
|
||||||
class="flex items-center gap-2 cursor-pointer"
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
@click="sortBy('Phone')"
|
>
|
||||||
>
|
<div
|
||||||
Phone <i class="fas" :class="getSortIcon('Phone')"></i>
|
class="flex items-center gap-2 cursor-pointer"
|
||||||
</div>
|
@click="sortBy('Email')"
|
||||||
</th>
|
>
|
||||||
<th class="px-6 py-3 whitespace-nowrap">
|
Contact
|
||||||
<div
|
<i
|
||||||
class="flex items-center gap-2 cursor-pointer"
|
class="fas"
|
||||||
@click="sortBy('RoleID')"
|
:class="getSortIcon('Email')"
|
||||||
>
|
></i>
|
||||||
Role <i class="fas" :class="getSortIcon('RoleID')"></i>
|
</div>
|
||||||
</div>
|
</th>
|
||||||
</th>
|
<th
|
||||||
<th class="px-6 py-3 whitespace-nowrap">Actions</th>
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap"
|
||||||
</tr>
|
>
|
||||||
</thead>
|
<div
|
||||||
|
class="flex items-center gap-2 cursor-pointer"
|
||||||
|
@click="sortBy('RoleID')"
|
||||||
|
>
|
||||||
|
Role
|
||||||
|
<i
|
||||||
|
class="fas"
|
||||||
|
:class="getSortIcon('RoleID')"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-100">
|
||||||
|
<template
|
||||||
|
x-for="volunteer in filteredVolunteers"
|
||||||
|
:key="volunteer.UserID"
|
||||||
|
>
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div
|
||||||
|
class="text-sm font-medium text-gray-900"
|
||||||
|
x-text="volunteer.UserID"
|
||||||
|
></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div
|
||||||
|
class="text-sm font-medium text-gray-900"
|
||||||
|
x-text="volunteer.FirstName + ' ' + volunteer.LastName"
|
||||||
|
></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div
|
||||||
|
class="text-sm text-gray-900"
|
||||||
|
x-text="volunteer.Email"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="text-sm text-gray-500"
|
||||||
|
x-text="volunteer.Phone"
|
||||||
|
></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full"
|
||||||
|
:class="getRoleBadgeClass(volunteer.RoleID)"
|
||||||
|
x-text="getRoleName(volunteer.RoleID)"
|
||||||
|
></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
class="text-blue-600 hover:text-blue-800 p-1"
|
||||||
|
@click="editVolunteer(volunteer)"
|
||||||
|
title="Edit volunteer"
|
||||||
|
>
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-red-600 hover:text-red-800 p-1"
|
||||||
|
@click="deleteVolunteer(volunteer.UserID)"
|
||||||
|
title="Delete volunteer"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
<!-- Table Body -->
|
<!-- No Results - Desktop -->
|
||||||
<tbody class="divide-y divide-gray-200">
|
<div
|
||||||
<template
|
x-show="filteredVolunteers.length === 0"
|
||||||
x-for="volunteer in filteredVolunteers"
|
class="px-6 py-8 text-center text-gray-500"
|
||||||
:key="volunteer.UserID"
|
|
||||||
>
|
|
||||||
<tr class="hover:bg-gray-50">
|
|
||||||
<td
|
|
||||||
class="px-6 py-3 whitespace-nowrap"
|
|
||||||
x-text="volunteer.UserID"
|
|
||||||
></td>
|
|
||||||
<td
|
|
||||||
class="px-6 py-3 whitespace-nowrap"
|
|
||||||
x-text="volunteer.FirstName"
|
|
||||||
></td>
|
|
||||||
<td
|
|
||||||
class="px-6 py-3 whitespace-nowrap"
|
|
||||||
x-text="volunteer.LastName"
|
|
||||||
></td>
|
|
||||||
<td
|
|
||||||
class="px-6 py-3 whitespace-nowrap"
|
|
||||||
x-text="volunteer.Email"
|
|
||||||
></td>
|
|
||||||
<td
|
|
||||||
class="px-6 py-3 whitespace-nowrap"
|
|
||||||
x-text="volunteer.Phone"
|
|
||||||
></td>
|
|
||||||
<td class="px-6 py-3 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800"
|
|
||||||
x-text="getRoleName(volunteer.RoleID)"
|
|
||||||
></span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3 whitespace-nowrap">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a
|
|
||||||
:href="`/volunteer/edit?id=${volunteer.UserID}`"
|
|
||||||
class="text-blue-600 hover:text-blue-800 font-medium text-xs px-2 py-1 hover:bg-blue-50 transition-colors"
|
|
||||||
>Edit</a
|
|
||||||
>
|
>
|
||||||
<form
|
No volunteers found
|
||||||
action="/volunteer/delete"
|
</div>
|
||||||
method="POST"
|
</div>
|
||||||
class="inline-block"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="id" :value="volunteer.UserID" />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="text-red-600 hover:text-red-800 font-medium text-xs px-2 py-1 hover:bg-red-50 transition-colors"
|
|
||||||
@click="return confirm('Are you sure you want to delete this volunteer?')"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- No Results Message -->
|
<!-- Mobile Cards -->
|
||||||
<div x-show="filteredVolunteers.length === 0" class="text-center py-12">
|
<div class="lg:hidden">
|
||||||
<i class="fas fa-search text-gray-400 text-3xl mb-4"></i>
|
<div class="space-y-4 p-4">
|
||||||
<p class="text-gray-600 text-lg mb-2">No volunteers found</p>
|
<template
|
||||||
<p class="text-gray-500 text-sm">
|
x-for="volunteer in filteredVolunteers"
|
||||||
Try adjusting your search or filter criteria
|
:key="volunteer.UserID"
|
||||||
</p>
|
>
|
||||||
|
<div
|
||||||
|
class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Card Header -->
|
||||||
|
<div
|
||||||
|
class="bg-gray-50 px-4 py-3 border-b border-gray-200 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<i class="fas fa-user text-gray-400"></i>
|
||||||
|
<span
|
||||||
|
class="text-sm font-semibold text-gray-900"
|
||||||
|
>Volunteer #<span
|
||||||
|
x-text="volunteer.UserID"
|
||||||
|
></span
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full"
|
||||||
|
:class="getRoleBadgeClass(volunteer.RoleID)"
|
||||||
|
x-text="getRoleName(volunteer.RoleID)"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Content -->
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium text-gray-900"
|
||||||
|
x-text="volunteer.FirstName + ' ' + volunteer.LastName"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-gray-500"
|
||||||
|
>Email</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-sm text-gray-900"
|
||||||
|
x-text="volunteer.Email"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phone -->
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-gray-500"
|
||||||
|
>Phone</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-sm text-gray-900"
|
||||||
|
x-text="volunteer.Phone"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div
|
||||||
|
class="flex justify-center space-x-4 pt-3 border-t border-gray-100"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex-1 px-4 py-2 bg-blue-500 text-white text-sm rounded-md hover:bg-blue-600 transition-colors font-medium"
|
||||||
|
@click="editVolunteer(volunteer)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-edit mr-1"></i> Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors text-sm font-medium"
|
||||||
|
@click="deleteVolunteer(volunteer.UserID)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash mr-1"></i> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- No Results - Mobile -->
|
||||||
|
<div
|
||||||
|
x-show="filteredVolunteers.length === 0"
|
||||||
|
class="text-center py-12"
|
||||||
|
>
|
||||||
|
<div class="text-gray-400 mb-4">
|
||||||
|
<i class="fas fa-users text-4xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
No volunteers found
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-500">
|
||||||
|
Try adjusting your search or filter criteria.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel Overlay -->
|
||||||
|
<div
|
||||||
|
id="volunteerPanelOverlay"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 hidden z-40"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Volunteer Panel -->
|
||||||
|
<div
|
||||||
|
id="volunteerPanel"
|
||||||
|
class="fixed top-0 right-0 h-full w-full max-w-md bg-white shadow-xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col"
|
||||||
|
>
|
||||||
|
<!-- Panel Header -->
|
||||||
|
<div
|
||||||
|
class="flex justify-between items-center px-6 py-4 border-b border-gray-200 bg-gray-50"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<i class="fas fa-user-plus text-blue-500" id="panelIcon"></i>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900" id="panelTitle">
|
||||||
|
Add Volunteer
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick="closeVolunteerPanel()"
|
||||||
|
class="text-gray-400 hover:text-gray-600 focus:outline-none p-1"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel Body -->
|
||||||
|
<form
|
||||||
|
id="volunteerForm"
|
||||||
|
method="POST"
|
||||||
|
action="/volunteer/add"
|
||||||
|
class="flex-1 overflow-y-auto p-6 space-y-6"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" id="volunteerId" />
|
||||||
|
|
||||||
|
<!-- First Name -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="firstName"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user mr-2 text-gray-400"></i>First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="firstName"
|
||||||
|
name="first_name"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Name -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="lastName"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user mr-2 text-gray-400"></i>Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="lastName"
|
||||||
|
name="last_name"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="email"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
<i class="fas fa-envelope mr-2 text-gray-400"></i>Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phone -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="phone"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
<i class="fas fa-phone mr-2 text-gray-400"></i>Phone
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Role Selection -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="role"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user-tag mr-2 text-gray-400"></i>Role
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="role_id"
|
||||||
|
id="role"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Role --</option>
|
||||||
|
<option value="1">Admin</option>
|
||||||
|
<option value="2">Team Leader</option>
|
||||||
|
<option value="3">Volunteer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Panel Footer -->
|
||||||
|
<div
|
||||||
|
class="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="closeVolunteerPanel()"
|
||||||
|
class="px-6 py-2 border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="volunteerForm"
|
||||||
|
id="submitBtn"
|
||||||
|
class="px-6 py-2 bg-blue-500 text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-check mr-2"></i>
|
||||||
|
<span id="submitText">Add Volunteer</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script
|
<script
|
||||||
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
|
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
|
||||||
defer
|
defer
|
||||||
></script>
|
></script>
|
||||||
<script>
|
<script>
|
||||||
function volunteerTable() {
|
function volunteerTable() {
|
||||||
return {
|
return {
|
||||||
searchTerm: '',
|
searchTerm: '',
|
||||||
roleFilter: '',
|
roleFilter: '',
|
||||||
sortColumn: '',
|
sortColumn: '',
|
||||||
sortDirection: 'asc',
|
sortDirection: 'asc',
|
||||||
volunteers: [
|
volunteers: [
|
||||||
{{ range .Users }}
|
{{ range .Users }}
|
||||||
{
|
{
|
||||||
UserID: {{ .UserID }},
|
UserID: {{ .UserID }},
|
||||||
FirstName: "{{ .FirstName }}",
|
FirstName: "{{ .FirstName }}",
|
||||||
LastName: "{{ .LastName }}",
|
LastName: "{{ .LastName }}",
|
||||||
Email: "{{ .Email }}",
|
Email: "{{ .Email }}",
|
||||||
Phone: "{{ .Phone }}",
|
Phone: "{{ .Phone }}",
|
||||||
RoleID: {{ .RoleID }}
|
RoleID: {{ .RoleID }}
|
||||||
},
|
},
|
||||||
{{ end }}
|
{{ end }}
|
||||||
],
|
],
|
||||||
|
|
||||||
get filteredVolunteers() {
|
get filteredVolunteers() {
|
||||||
let filtered = this.volunteers.filter(volunteer => {
|
let filtered = this.volunteers.filter(volunteer => {
|
||||||
// Search filter
|
// Search filter
|
||||||
const searchMatch = !this.searchTerm ||
|
const searchMatch = !this.searchTerm ||
|
||||||
volunteer.FirstName.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
|
volunteer.FirstName.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
|
||||||
volunteer.LastName.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
|
volunteer.LastName.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
|
||||||
volunteer.Email.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
|
volunteer.Email.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
|
||||||
volunteer.Phone.includes(this.searchTerm);
|
volunteer.Phone.includes(this.searchTerm);
|
||||||
|
|
||||||
// Role filter
|
// Role filter
|
||||||
const roleMatch = !this.roleFilter || volunteer.RoleID.toString() === this.roleFilter;
|
const roleMatch = !this.roleFilter || volunteer.RoleID.toString() === this.roleFilter;
|
||||||
|
|
||||||
return searchMatch && roleMatch;
|
return searchMatch && roleMatch;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort filtered results
|
// Sort filtered results
|
||||||
if (this.sortColumn) {
|
if (this.sortColumn) {
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
let aValue = a[this.sortColumn];
|
let aValue = a[this.sortColumn];
|
||||||
let bValue = b[this.sortColumn];
|
let bValue = b[this.sortColumn];
|
||||||
|
|
||||||
// Handle string comparison
|
// Handle string comparison
|
||||||
if (typeof aValue === 'string') {
|
if (typeof aValue === 'string') {
|
||||||
aValue = aValue.toLowerCase();
|
aValue = aValue.toLowerCase();
|
||||||
bValue = bValue.toLowerCase();
|
bValue = bValue.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
let comparison = 0;
|
let comparison = 0;
|
||||||
if (aValue > bValue) comparison = 1;
|
if (aValue > bValue) comparison = 1;
|
||||||
if (aValue < bValue) comparison = -1;
|
if (aValue < bValue) comparison = -1;
|
||||||
|
|
||||||
return this.sortDirection === 'asc' ? comparison : -comparison;
|
return this.sortDirection === 'asc' ? comparison : -comparison;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
},
|
},
|
||||||
|
|
||||||
sortBy(column) {
|
sortBy(column) {
|
||||||
if (this.sortColumn === column) {
|
if (this.sortColumn === column) {
|
||||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
} else {
|
} else {
|
||||||
this.sortColumn = column;
|
this.sortColumn = column;
|
||||||
this.sortDirection = 'asc';
|
this.sortDirection = 'asc';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getSortIcon(column) {
|
getSortIcon(column) {
|
||||||
if (this.sortColumn !== column) {
|
if (this.sortColumn !== column) {
|
||||||
return 'fa-sort text-gray-400';
|
return 'fa-sort text-gray-400';
|
||||||
}
|
}
|
||||||
return this.sortDirection === 'asc' ? 'fa-sort-up text-blue-600' : 'fa-sort-down text-blue-600';
|
return this.sortDirection === 'asc' ? 'fa-sort-up text-blue-600' : 'fa-sort-down text-blue-600';
|
||||||
},
|
},
|
||||||
|
|
||||||
getRoleName(roleId) {
|
getRoleName(roleId) {
|
||||||
switch (roleId) {
|
switch (roleId) {
|
||||||
case 1: return 'Admin';
|
case 1: return 'Admin';
|
||||||
case 2: return 'Team Leader';
|
case 2: return 'Team Leader';
|
||||||
case 3: return 'Volunteer';
|
case 3: return 'Volunteer';
|
||||||
default: return 'Unknown';
|
default: return 'Unknown';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clearFilters() {
|
getRoleBadgeClass(roleId) {
|
||||||
this.searchTerm = '';
|
switch (roleId) {
|
||||||
this.roleFilter = '';
|
case 1: return 'bg-red-100 text-red-700'; // Admin - Red
|
||||||
this.sortColumn = '';
|
case 2: return 'bg-blue-100 text-blue-700'; // Team Leader - Blue
|
||||||
this.sortDirection = 'asc';
|
case 3: return 'bg-green-100 text-green-700'; // Volunteer - Green
|
||||||
}
|
default: return 'bg-gray-100 text-gray-700';
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
|
clearFilters() {
|
||||||
|
this.searchTerm = '';
|
||||||
|
this.roleFilter = '';
|
||||||
|
this.sortColumn = '';
|
||||||
|
this.sortDirection = 'asc';
|
||||||
|
},
|
||||||
|
|
||||||
|
editVolunteer(volunteer) {
|
||||||
|
openEditVolunteerPanel(volunteer);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteVolunteer(volunteerId) {
|
||||||
|
if (confirm('Are you sure you want to delete this volunteer?')) {
|
||||||
|
// Create and submit form for deletion
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '/volunteer/delete';
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'id';
|
||||||
|
input.value = volunteerId;
|
||||||
|
|
||||||
|
form.appendChild(input);
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel functions
|
||||||
|
function openAddVolunteerPanel() {
|
||||||
|
document.getElementById('panelTitle').textContent = 'Add Volunteer';
|
||||||
|
document.getElementById('panelIcon').className = 'fas fa-user-plus text-blue-500';
|
||||||
|
document.getElementById('submitText').textContent = 'Add Volunteer';
|
||||||
|
document.getElementById('volunteerForm').action = '/volunteer/add';
|
||||||
|
document.getElementById('volunteerForm').reset();
|
||||||
|
document.getElementById('volunteerId').value = '';
|
||||||
|
|
||||||
|
showPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditVolunteerPanel(volunteer) {
|
||||||
|
document.getElementById('panelTitle').textContent = 'Edit Volunteer';
|
||||||
|
document.getElementById('panelIcon').className = 'fas fa-user-edit text-blue-500';
|
||||||
|
document.getElementById('submitText').textContent = 'Update Volunteer';
|
||||||
|
document.getElementById('volunteerForm').action = '/volunteer/edit';
|
||||||
|
|
||||||
|
// Populate form
|
||||||
|
document.getElementById('volunteerId').value = volunteer.UserID;
|
||||||
|
document.getElementById('firstName').value = volunteer.FirstName;
|
||||||
|
document.getElementById('lastName').value = volunteer.LastName;
|
||||||
|
document.getElementById('email').value = volunteer.Email;
|
||||||
|
document.getElementById('phone').value = volunteer.Phone;
|
||||||
|
document.getElementById('role').value = volunteer.RoleID;
|
||||||
|
|
||||||
|
showPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPanel() {
|
||||||
|
document.getElementById('volunteerPanelOverlay').classList.remove('hidden');
|
||||||
|
document.getElementById('volunteerPanel').classList.remove('translate-x-full');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('firstName').focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeVolunteerPanel() {
|
||||||
|
document.getElementById('volunteerPanel').classList.add('translate-x-full');
|
||||||
|
document.getElementById('volunteerPanelOverlay').classList.add('hidden');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('volunteerForm').reset();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Close panel when clicking overlay
|
||||||
|
document.getElementById('volunteerPanelOverlay').addEventListener('click', closeVolunteerPanel);
|
||||||
|
|
||||||
|
// Close panel on Escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const overlay = document.getElementById('volunteerPanelOverlay');
|
||||||
|
if (!overlay.classList.contains('hidden')) {
|
||||||
|
closeVolunteerPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Consistent styling */
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive adjustments */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
--tw-space-x-reverse: 0;
|
||||||
|
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
||||||
|
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -3,132 +3,88 @@
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="flex-1 overflow-hidden bg-gray-50">
|
<div class="flex-1 overflow-hidden bg-gray-50">
|
||||||
<div class="h-screen flex flex-col lg:flex-row gap-6 p-4 sm:p-6">
|
<div class="h-screen flex flex-col lg:flex-row gap-6 p-4 sm:p-6">
|
||||||
<!-- Left Column - Posts -->
|
|
||||||
<div
|
<!-- Left Column - Stats -->
|
||||||
class="w-full lg:w-1/2 flex flex-col gap-4 sm:gap-6 sticky top-0 self-start h-fit"
|
<div class="w-full lg:w-1/2 flex flex-col gap-6 sticky top-0 self-start h-fit">
|
||||||
>
|
|
||||||
<!-- Today's Overview -->
|
<!-- Today's Overview -->
|
||||||
<div class="bg-white border-b border-gray-200" x-data="{ open: true }">
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200" x-data="{ open: true }">
|
||||||
<div
|
<div class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer border-b border-gray-100" @click="open = !open">
|
||||||
class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer"
|
<h3 class="text-sm font-semibold text-gray-900">Today's Overview</h3>
|
||||||
@click="open = !open"
|
<i class="fas" :class="open ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
|
||||||
>
|
|
||||||
<h3 class="text-sm font-semibold text-gray-900">
|
|
||||||
Today's Overview
|
|
||||||
</h3>
|
|
||||||
<i
|
|
||||||
class="fas"
|
|
||||||
:class="open ? 'fa-chevron-up' : 'fa-chevron-down'"
|
|
||||||
></i>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 sm:px-6 pb-4" x-show="open" x-collapse>
|
<div class="px-4 sm:px-6 py-4" x-show="open" x-collapse>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0"
|
|
||||||
>
|
|
||||||
<i class="fas fa-calendar-day text-gray-600 text-xs"></i>
|
<i class="fas fa-calendar-day text-gray-600 text-xs"></i>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-gray-700">Appointments Today</span>
|
<span class="text-sm text-gray-700">Appointments Today</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-lg font-semibold text-gray-900"
|
<span class="text-lg font-semibold text-gray-900">{{ .Statistics.AppointmentsToday }}</span>
|
||||||
>{{ .Statistics.AppointmentsToday }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0"
|
|
||||||
>
|
|
||||||
<i class="fas fa-calendar-week text-gray-600 text-xs"></i>
|
<i class="fas fa-calendar-week text-gray-600 text-xs"></i>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-gray-700"
|
<span class="text-sm text-gray-700">Appointments Tomorrow</span>
|
||||||
>Appointments Tomorrow</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="text-lg font-semibold text-gray-900"
|
<span class="text-lg font-semibold text-gray-900">{{ .Statistics.AppointmentsTomorrow }}</span>
|
||||||
>{{ .Statistics.AppointmentsTomorrow }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0"
|
<i class="fas fa-calendar-alt text-gray-600 text-xs"></i>
|
||||||
>
|
|
||||||
<i class="fas fa-calendar-week text-gray-600 text-xs"></i>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-gray-700">This Week</span>
|
<span class="text-sm text-gray-700">This Week</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-lg font-semibold text-gray-900"
|
<span class="text-lg font-semibold text-gray-900">{{ .Statistics.AppointmentsThisWeek }}</span>
|
||||||
>{{ .Statistics.AppointmentsThisWeek }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Polling Progress -->
|
<!-- Polling Progress -->
|
||||||
<div class="bg-white border-b border-gray-200" x-data="{ open: true }">
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200" x-data="{ open: true }">
|
||||||
<div
|
<div class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer border-b border-gray-100" @click="open = !open">
|
||||||
class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer"
|
<h3 class="text-sm font-semibold text-gray-900">Polling Progress</h3>
|
||||||
@click="open = !open"
|
<i class="fas" :class="open ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
|
||||||
>
|
|
||||||
<h3 class="text-sm font-semibold text-gray-900">
|
|
||||||
Polling Progress
|
|
||||||
</h3>
|
|
||||||
<i
|
|
||||||
class="fas"
|
|
||||||
:class="open ? 'fa-chevron-up' : 'fa-chevron-down'"
|
|
||||||
></i>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 sm:px-6 pb-4" x-show="open" x-collapse>
|
<div class="px-4 sm:px-6 py-4" x-show="open" x-collapse>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0"
|
|
||||||
>
|
|
||||||
<i class="fas fa-check-circle text-green-600 text-xs"></i>
|
<i class="fas fa-check-circle text-green-600 text-xs"></i>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-gray-700">Polls Completed</span>
|
<span class="text-sm text-gray-700">Polls Completed</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-lg font-semibold text-green-600"
|
<span class="text-lg font-semibold text-green-600">{{ .Statistics.PollsCompleted }}</span>
|
||||||
>{{ .Statistics.PollsCompleted }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0"
|
|
||||||
>
|
|
||||||
<i class="fas fa-clock text-orange-600 text-xs"></i>
|
<i class="fas fa-clock text-orange-600 text-xs"></i>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-gray-700">Polls Remaining</span>
|
<span class="text-sm text-gray-700">Polls Remaining</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-lg font-semibold text-orange-600"
|
<span class="text-lg font-semibold text-orange-600">{{ .Statistics.PollsRemaining }}</span>
|
||||||
>{{ .Statistics.PollsRemaining }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress Bar -->
|
<!-- Progress Bar -->
|
||||||
{{ if gt .Statistics.TotalAppointments 0 }}
|
{{ if gt .Statistics.TotalAppointments 0 }}
|
||||||
<div class="mt-4">
|
<div class="mt-4 w-[250px]">
|
||||||
<div class="flex justify-between text-xs text-gray-600 mb-2">
|
<div class="flex justify-between text-xs text-gray-600 mb-2">
|
||||||
<span>Progress</span>
|
<span>Progress</span>
|
||||||
<span
|
<span>{{ .Statistics.PollsCompleted }}/{{ .Statistics.TotalAppointments }}</span>
|
||||||
>{{ .Statistics.PollsCompleted }}/{{
|
|
||||||
.Statistics.TotalAppointments }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
<div
|
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: {{ .Statistics.PollCompletionPercent }}%"></div>
|
||||||
class="bg-gray-600 h-2 rounded-full transition-all duration-300"
|
|
||||||
style="width: {{ .Statistics.PollCompletionPercent }}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -137,29 +93,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Team Members -->
|
<!-- Team Members -->
|
||||||
<div class="bg-white border-b border-gray-200" x-data="{ open: true }">
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200" x-data="{ open: true }">
|
||||||
<div
|
<div class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer border-b border-gray-100" @click="open = !open">
|
||||||
class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer"
|
|
||||||
@click="open = !open"
|
|
||||||
>
|
|
||||||
<h3 class="text-sm font-semibold text-gray-900">Team Members</h3>
|
<h3 class="text-sm font-semibold text-gray-900">Team Members</h3>
|
||||||
<i
|
<i class="fas" :class="open ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
|
||||||
class="fas"
|
|
||||||
:class="open ? 'fa-chevron-up' : 'fa-chevron-down'"
|
|
||||||
></i>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 sm:px-6 pb-4" x-show="open" x-collapse>
|
<div class="px-4 sm:px-6 py-4" x-show="open" x-collapse>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{{ range .Teammates }}
|
{{ range .Teammates }}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="text-sm font-medium text-gray-900">
|
||||||
{{ .FullName }} {{ if .IsLead }}
|
{{ .FullName }}
|
||||||
<span class="ml-2 text-xs text-blue-600 font-semibold"
|
{{ if .IsLead }}
|
||||||
>{{ .Role }}</span
|
<span class="ml-2 text-xs text-blue-600 font-semibold">{{ .Role }}</span>
|
||||||
>
|
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<span class="ml-2 text-xs text-gray-500">{{ .Role }}</span>
|
<span class="ml-2 text-xs text-gray-500">{{ .Role }}</span>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,85 +123,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Right Column - Statistics -->
|
|
||||||
<div class="flex-1 lg:flex-none lg:w-1/2 overflow-y-auto pr-2">
|
<!-- Right Column - Posts -->
|
||||||
{{ if .Posts }}{{range .Posts}}
|
<div class="flex-1 lg:w-1/2 overflow-y-auto pr-2">
|
||||||
<!-- Posts Feed -->
|
{{ if .Posts }}
|
||||||
<article class="bg-white border-b border-gray-200">
|
{{ range .Posts }}
|
||||||
<!-- Post Header -->
|
<article class="bg-white rounded-lg shadow-sm border border-gray-200 mb-4">
|
||||||
<div class="flex items-center px-6 py-4">
|
<!-- Post Header -->
|
||||||
<div class="flex-shrink-0">
|
<div class="flex items-center px-6 py-4 border-b border-gray-100">
|
||||||
<div
|
<div class="flex-shrink-0">
|
||||||
class="w-10 h-10 bg-blue-500 flex items-center justify-center text-white font-semibold"
|
<div class="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-white font-semibold">
|
||||||
>
|
{{ slice .AuthorName 0 1 }}
|
||||||
{{slice .AuthorName 0 1}}
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-semibold text-gray-900">{{ .AuthorName }}</p>
|
||||||
|
<p class="text-xs text-gray-500">{{ .CreatedAt.Format "Jan 2, 2006" }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-semibold text-gray-900">{{.AuthorName}}</p>
|
<!-- Post Image -->
|
||||||
<p class="text-xs text-gray-500">
|
{{ if .ImageURL }}
|
||||||
{{.CreatedAt.Format "Jan 2, 2006"}}
|
<div class="w-full">
|
||||||
|
<img src="{{ .ImageURL }}" alt="Post image" class="w-full max-h-96 object-cover" onerror="this.parentElement.style.display='none'" />
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<!-- Post Content -->
|
||||||
|
{{ if .Content }}
|
||||||
|
<div class="px-6 pt-3 pb-4">
|
||||||
|
<p class="text-gray-900 leading-relaxed">
|
||||||
|
<span class="font-semibold">{{ .AuthorName }}</span> {{ .Content }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{{ end }}
|
||||||
|
</article>
|
||||||
<!-- Post Image -->
|
{{ end }}
|
||||||
{{if .ImageURL}}
|
{{ else }}
|
||||||
<div class="w-full">
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
|
||||||
<img
|
<div class="max-w-sm mx-auto">
|
||||||
src="{{.ImageURL}}"
|
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
alt="Post image"
|
<i class="fas fa-inbox text-2xl text-gray-400"></i>
|
||||||
class="w-full max-h-96 object-cover"
|
</div>
|
||||||
onerror="this.parentElement.style.display='none'"
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts yet</h3>
|
||||||
/>
|
<p class="text-sm text-gray-500">Be the first to share something with the community!</p>
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<!-- Post Content -->
|
|
||||||
{{if .Content}}
|
|
||||||
<div class="px-6 pt-2 pb-4">
|
|
||||||
<p class="text-gray-900 leading-relaxed">
|
|
||||||
<span class="font-semibold">{{.AuthorName}}</span> {{.Content}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</article>
|
|
||||||
{{else}}
|
|
||||||
<div class="bg-white p-12 text-center">
|
|
||||||
<div class="max-w-sm mx-auto">
|
|
||||||
<svg
|
|
||||||
class="w-16 h-16 mx-auto text-gray-300 mb-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts yet</h3>
|
|
||||||
<p class="text-gray-500">
|
|
||||||
Be the first to share something with the community!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ end }} {{ else }}
|
|
||||||
<div class="bg-white border-b border-gray-200 p-8 sm:p-12 text-center">
|
|
||||||
<div class="max-w-sm mx-auto">
|
|
||||||
<div
|
|
||||||
class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4"
|
|
||||||
>
|
|
||||||
<i class="fas fa-inbox text-2xl text-gray-400"></i>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-600 font-medium mb-2">No posts yet</p>
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
Check back later for updates from your team
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
134
app/internal/templates/volunteer_schedule.html
Normal file
134
app/internal/templates/volunteer_schedule.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<div class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="bg-white border-b border-gray-200 px-4 md:px-6 py-4 flex justify-between items-center">
|
||||||
|
<h1 class="text-lg font-semibold text-gray-900">My Schedule</h1>
|
||||||
|
<button
|
||||||
|
onclick="openAddAvailabilityPanel()"
|
||||||
|
class="px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-lg hover:bg-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus mr-2"></i> Add Availability
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table Container -->
|
||||||
|
<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">
|
||||||
|
<!-- Desktop Table -->
|
||||||
|
<div class="hidden lg:block overflow-x-auto">
|
||||||
|
<table class="w-full min-w-full">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">Day</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">Start Time</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">End Time</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-100">
|
||||||
|
{{ range .Availability }}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-900">{{ .DayOfWeek }}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-900">{{ .StartTime }}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-900">{{ .EndTime }}</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<form method="POST" action="/volunteer/schedule/delete" class="inline-block">
|
||||||
|
<input type="hidden" name="id" value="{{.AvailabilityID}}" />
|
||||||
|
<button type="submit" class="text-red-500 hover:text-red-700 p-1" title="Delete">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-6 py-8 text-center text-gray-500">
|
||||||
|
No availability set yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Cards -->
|
||||||
|
<div class="lg:hidden p-4 space-y-4">
|
||||||
|
{{ range .Availability }}
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-4 space-y-3">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm font-semibold text-gray-900">{{ .DayOfWeek }}</span>
|
||||||
|
<form method="POST" action="/volunteer/schedule/delete">
|
||||||
|
<input type="hidden" name="id" value="{{.AvailabilityID}}" />
|
||||||
|
<button type="submit" class="text-red-500 hover:text-red-700">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm text-gray-600">
|
||||||
|
<span>Start</span>
|
||||||
|
<span class="font-medium text-gray-900">{{ .StartTime }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm text-gray-600">
|
||||||
|
<span>End</span>
|
||||||
|
<span class="font-medium text-gray-900">{{ .EndTime }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="text-center py-8 text-gray-500">No availability set yet</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Availability Drawer -->
|
||||||
|
<div id="addAvailabilityOverlay" class="fixed inset-0 bg-black bg-opacity-50 hidden z-40"></div>
|
||||||
|
<div id="addAvailabilityPanel" class="fixed top-0 right-0 h-full w-full max-w-md bg-white shadow-xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col">
|
||||||
|
<!-- Panel Header -->
|
||||||
|
<div class="flex justify-between items-center px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Add Availability</h2>
|
||||||
|
<button onclick="closeAddAvailabilityPanel()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel Body -->
|
||||||
|
<form method="POST" id="assignForm" action="/volunteer/schedule/post" class="flex-1 p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Day</label>
|
||||||
|
<input type="date" name="day" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Start Time</label>
|
||||||
|
<input type="time" name="start_time" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">End Time</label>
|
||||||
|
<input type="time" name="end_time" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Panel Footer -->
|
||||||
|
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||||
|
<button type="button" onclick="closeAddAvailabilityPanel()" class="px-6 py-2 border border-gray-300 text-gray-700 bg-white rounded-lg hover:bg-gray-50">Cancel</button>
|
||||||
|
<button type="submit" form="assignForm" class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
||||||
|
<i class="fas fa-check mr-2"></i> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openAddAvailabilityPanel() {
|
||||||
|
document.getElementById("addAvailabilityOverlay").classList.remove("hidden");
|
||||||
|
document.getElementById("addAvailabilityPanel").classList.remove("translate-x-full");
|
||||||
|
}
|
||||||
|
function closeAddAvailabilityPanel() {
|
||||||
|
document.getElementById("addAvailabilityOverlay").classList.add("hidden");
|
||||||
|
document.getElementById("addAvailabilityPanel").classList.add("translate-x-full");
|
||||||
|
}
|
||||||
|
document.getElementById("addAvailabilityOverlay").addEventListener("click", closeAddAvailabilityPanel);
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape") closeAddAvailabilityPanel();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
65
app/main.go
65
app/main.go
@@ -16,35 +16,6 @@ import (
|
|||||||
_ "github.com/lib/pq" // use PostgreSQL
|
_ "github.com/lib/pq" // use PostgreSQL
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helper function to determine navigation visibility based on role
|
|
||||||
func getNavFlags(role int) (bool, bool, bool) {
|
|
||||||
showAdminNav := role == 1 // Admin role
|
|
||||||
showLeaderNav := role == 2 // Team Leader role
|
|
||||||
showVolunteerNav := role == 3 // Volunteer role
|
|
||||||
return showAdminNav, showVolunteerNav, showLeaderNav
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to create template data with proper nav flags
|
|
||||||
func createTemplateData(title, activeSection string, role int, isAuthenticated bool, additionalData map[string]interface{}) map[string]interface{} {
|
|
||||||
showAdminNav, showVolunteerNav, _ := getNavFlags(role)
|
|
||||||
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"Title": title,
|
|
||||||
"IsAuthenticated": isAuthenticated,
|
|
||||||
"Role": role,
|
|
||||||
"ShowAdminNav": showAdminNav,
|
|
||||||
"ShowVolunteerNav": showVolunteerNav,
|
|
||||||
"ActiveSection": activeSection,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add any additional data
|
|
||||||
for key, value := range additionalData {
|
|
||||||
data[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
err := godotenv.Load()
|
err := godotenv.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,12 +74,6 @@ func volunteerMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func schedualHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
role := r.Context().Value("user_role").(int)
|
|
||||||
data := createTemplateData("My Schedule", "schedual", role, true, nil)
|
|
||||||
utils.Render(w, "Schedual/schedual.html", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func HomeHandler(w http.ResponseWriter, r *http.Request) {
|
func HomeHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
utils.Render(w, "dashboard.html", map[string]interface{}{
|
utils.Render(w, "dashboard.html", map[string]interface{}{
|
||||||
"Title": "Admin Dashboard",
|
"Title": "Admin Dashboard",
|
||||||
@@ -116,14 +81,15 @@ func HomeHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
"ActiveSection": "dashboard",
|
"ActiveSection": "dashboard",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
models.InitDB()
|
models.InitDB()
|
||||||
|
|
||||||
|
models.EmailMessage("hellow")
|
||||||
|
|
||||||
// Static file servers
|
// Static file servers
|
||||||
fs := http.FileServer(http.Dir("static"))
|
fs := http.FileServer(http.Dir("static"))
|
||||||
http.Handle("/static/", http.StripPrefix("/static/", fs))
|
http.Handle("/static/", http.StripPrefix("/static/", fs))
|
||||||
|
|
||||||
uploadsFs := http.FileServer(http.Dir("uploads"))
|
uploadsFs := http.FileServer(http.Dir("uploads"))
|
||||||
http.Handle("/uploads/", http.StripPrefix("/uploads/", uploadsFs))
|
http.Handle("/uploads/", http.StripPrefix("/uploads/", uploadsFs))
|
||||||
|
|
||||||
@@ -143,31 +109,32 @@ func main() {
|
|||||||
http.HandleFunc("/dashboard", adminMiddleware(handlers.AdminDashboardHandler))
|
http.HandleFunc("/dashboard", adminMiddleware(handlers.AdminDashboardHandler))
|
||||||
http.HandleFunc("/volunteers", adminMiddleware(handlers.VolunteerHandler))
|
http.HandleFunc("/volunteers", adminMiddleware(handlers.VolunteerHandler))
|
||||||
http.HandleFunc("/volunteer/edit", adminMiddleware(handlers.EditVolunteerHandler))
|
http.HandleFunc("/volunteer/edit", adminMiddleware(handlers.EditVolunteerHandler))
|
||||||
|
|
||||||
http.HandleFunc("/team_builder", adminMiddleware(handlers.TeamBuilderHandler))
|
http.HandleFunc("/team_builder", adminMiddleware(handlers.TeamBuilderHandler))
|
||||||
http.HandleFunc("/team_builder/remove_volunteer", adminMiddleware(handlers.RemoveVolunteerHandler))
|
http.HandleFunc("/team_builder/remove_volunteer", adminMiddleware(handlers.RemoveVolunteerHandler))
|
||||||
|
|
||||||
http.HandleFunc("/addresses", adminMiddleware(handlers.AddressHandler))
|
http.HandleFunc("/addresses", adminMiddleware(handlers.AddressHandler))
|
||||||
http.HandleFunc("/assign_address", adminMiddleware(handlers.AssignAddressHandler))
|
http.HandleFunc("/assign_address", adminMiddleware(handlers.AssignAddressHandler))
|
||||||
http.HandleFunc("/remove_assigned_address", adminMiddleware(handlers.RemoveAssignedAddressHandler))
|
http.HandleFunc("/remove_assigned_address", adminMiddleware(handlers.RemoveAssignedAddressHandler))
|
||||||
|
|
||||||
http.HandleFunc("/addresses/upload-csv", adminMiddleware(handlers.CSVUploadHandler))
|
http.HandleFunc("/addresses/upload-csv", adminMiddleware(handlers.CSVUploadHandler))
|
||||||
http.HandleFunc("/reports", adminMiddleware(handlers.ReportsHandler))
|
http.HandleFunc("/reports", adminMiddleware(handlers.ReportsHandler))
|
||||||
|
|
||||||
http.HandleFunc("/posts", adminMiddleware(handlers.PostsHandler))
|
http.HandleFunc("/posts", adminMiddleware(handlers.PostsHandler))
|
||||||
|
|
||||||
|
// Assignment management routes (Admin only)
|
||||||
|
// API Routes
|
||||||
http.HandleFunc("/api/validated-addresses", handlers.GetValidatedAddressesHandler)
|
http.HandleFunc("/api/validated-addresses", handlers.GetValidatedAddressesHandler)
|
||||||
http.HandleFunc("/api/validated-addresses/stats", handlers.GetValidatedAddressesStatsHandler)
|
http.HandleFunc("/api/validated-addresses/stats", handlers.GetValidatedAddressesStatsHandler)
|
||||||
|
|
||||||
|
|
||||||
//--- Volunteer-only routes
|
//--- Volunteer-only routes
|
||||||
http.HandleFunc("/volunteer/dashboard", volunteerMiddleware(handlers.VolunteerPostsHandler))
|
http.HandleFunc("/volunteer/dashboard", volunteerMiddleware(handlers.VolunteerPostsHandler))
|
||||||
http.HandleFunc("/volunteer/Addresses", volunteerMiddleware(handlers.VolunteerAppointmentHandler))
|
http.HandleFunc("/volunteer/addresses", volunteerMiddleware(handlers.VolunteerAppointmentHandler))
|
||||||
http.HandleFunc("/schedual", volunteerMiddleware(schedualHandler))
|
|
||||||
|
// Schedule/Availability routes (Volunteer only)
|
||||||
|
http.HandleFunc("/volunteer/schedule", volunteerMiddleware(handlers.VolunteerGetAvailabilityHandler))
|
||||||
|
http.HandleFunc("/volunteer/schedule/post", volunteerMiddleware(handlers.VolunteerPostScheduleHandler))
|
||||||
|
http.HandleFunc("/volunteer/schedule/delete", volunteerMiddleware(handlers.VolunteerDeleteScheduleHandler))
|
||||||
|
|
||||||
// Poll routes (volunteer only)
|
// Poll routes (volunteer only)
|
||||||
http.HandleFunc("/poll", volunteerMiddleware(handlers.PollHandler))
|
http.HandleFunc("/poll", volunteerMiddleware(handlers.PollHandler))
|
||||||
|
|
||||||
log.Println("Server started on http://localhost:8080")
|
log.Println("Server started on http://localhost:8080")
|
||||||
log.Fatal(http.ListenAndServe("0.0.0.0:8080", nil))
|
log.Fatal(http.ListenAndServe("0.0.0.0:8080", nil))
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 641 KiB |
Reference in New Issue
Block a user