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:
Mann Patel
2025-09-11 16:54:30 -06:00
parent 144436bbf3
commit b21e76eed0
19 changed files with 1953 additions and 1442 deletions

View File

@@ -1,16 +1,7 @@
# Poll-system
- TODO: volunteer Available
- TODO: Update assign address func to take into account availability
- 18'' Metal Ruler
- Sketching Material (dollaram)(sketch)
- Adhesive (staples/dollaram)
Done:
- Exacto
- A Large Cutting Mat
- Black Construction Paper
- And a lock for your locker
- White Foam Core or Cardstock Paper (dollaram)
- TODO: Add Error popups
- 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
- TODO: Square payment page qr code

View File

@@ -13,12 +13,7 @@ CREATE TABLE users (
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(150) UNIQUE NOT NULL,
phone VARCHAR(20),
password TEXT NOT NULL,
role_id INT REFERENCES role(role_id) ON DELETE SET NULL,
admin_code CHAR(6) UNIQUE,
-- Admin settings combined here
ward_settings TEXT,
phone VARCHAR(20) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
@@ -100,7 +95,7 @@ CREATE TABLE post (
);
CREATE TABLE availability (
availability_id SERIAL PRIMARY KEY,
sched_id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(user_id) ON DELETE CASCADE,
day_of_week VARCHAR(20),
start_time TIME,
@@ -139,3 +134,6 @@ INSERT INTO role (role_id, name) VALUES
(2, 'team_lead'),
(3, 'volunteer')
ON CONFLICT DO NOTHING;
ALTER TABLE availability
ADD CONSTRAINT availability_user_day_unique UNIQUE (user_id, day_of_week);

View File

@@ -5,7 +5,6 @@ import (
"net/http"
"strconv"
"time"
"fmt"
"github.com/patel-mann/poll-system/app/internal/models"
"github.com/patel-mann/poll-system/app/internal/utils"
@@ -49,7 +48,6 @@ func AddressHandler(w http.ResponseWriter, r *http.Request) {
pageSizeStr := r.URL.Query().Get("pageSize")
username, _ := models.GetCurrentUserName(r)
page := 1
pageSize := 20
if pageStr != "" {
@@ -157,7 +155,7 @@ func AddressHandler(w http.ResponseWriter, r *http.Request) {
}
// Get users associated with this admin
currentAdminID := r.Context().Value("user_id").(int)
currentAdminID := models.GetCurrentUserID(w, r)
userRows, err := models.DB.Query(`
SELECT u.user_id, u.first_name || ' ' || u.last_name AS name
FROM users u
@@ -267,13 +265,9 @@ func AssignAddressHandler(w http.ResponseWriter, r *http.Request) {
appointmentDate := r.FormValue("appointment_date")
startTime := r.FormValue("time")
if userIDStr == "" || addressIDStr == "" {
http.Error(w, "User ID and Address ID are required", http.StatusBadRequest)
return
}
if appointmentDate == "" || startTime == "" {
http.Error(w, "Appointment date and start time are required", http.StatusBadRequest)
// Basic validation
if userIDStr == "" || addressIDStr == "" || appointmentDate == "" || startTime == "" {
http.Error(w, "All fields are required", http.StatusBadRequest)
return
}
@@ -289,54 +283,30 @@ func AssignAddressHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Parse and validate the appointment date
// Parse date
parsedDate, err := time.Parse("2006-01-02", appointmentDate)
if err != nil {
http.Error(w, "Invalid appointment date format", http.StatusBadRequest)
return
}
// Validate that the appointment date is not in the past
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
// Parse time
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 {
log.Println("User verification error:", err)
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if userExists == 0 {
http.Error(w, "Invalid user selection", http.StatusBadRequest)
http.Error(w, "Invalid appointment time format", http.StatusBadRequest)
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
err = models.DB.QueryRow(`
SELECT COUNT(*) FROM appointment
WHERE address_id = $1
`, addressID).Scan(&exists)
err = models.DB.QueryRow(`SELECT COUNT(*) FROM appointment WHERE address_id = $1`, addressID).Scan(&exists)
if err != nil {
log.Println("Assignment check error:", err)
http.Error(w, "Database error", http.StatusInternalServerError)
@@ -347,38 +317,39 @@ func AssignAddressHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Check if the user already has an appointment at the same date and time
var timeConflict int
// Check for conflicting appointment for the user
var conflict int
err = models.DB.QueryRow(`
SELECT COUNT(*) FROM appointment
WHERE user_id = $1 AND appointment_date = $2 AND appointment_time = $3
`, userID, appointmentDate, startTime).Scan(&timeConflict)
WHERE user_id = $1 AND appointment_date = $2 AND appointment_time = $3`,
userID, appointmentDate, startTime).Scan(&conflict)
if err != nil {
log.Println("Time conflict check error:", err)
log.Println("Conflict check error:", err)
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if timeConflict > 0 {
if conflict > 0 {
http.Error(w, "User already has an appointment at this date and time", http.StatusBadRequest)
return
}
// Assign the address - create appointment with specific date and time
// Insert the appointment anyway
_, err = models.DB.Exec(`
INSERT INTO appointment (user_id, address_id, appointment_date, appointment_time, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())
`, userID, addressID, appointmentDate, startTime)
VALUES ($1, $2, $3, $4, NOW(), NOW())`,
userID, addressID, appointmentDate, startTime)
if err != nil {
log.Println("Assignment error:", err)
log.Println("Insert appointment error:", err)
http.Error(w, "Failed to assign address", http.StatusInternalServerError)
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)
}
func RemoveAssignedAddressHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/addresses", http.StatusSeeOther)

View File

@@ -109,7 +109,6 @@ func VolunteerPostsHandler(w http.ResponseWriter, r *http.Request) {
teammates = append(teammates, t)
}
// Get volunteer statistics
stats, err := getVolunteerStatistics(CurrentUserID)
@@ -160,7 +159,6 @@ func getVolunteerStatistics(userID int) (*VolunteerStatistics, error) {
fmt.Println("Week Start:", weekStart.Format("2006-01-02"))
fmt.Println("Week End:", weekEnd.Format("2006-01-02"))
// Appointments today
err := models.DB.QueryRow(`
SELECT COUNT(*)
@@ -191,7 +189,6 @@ func getVolunteerStatistics(userID int) (*VolunteerStatistics, error) {
return nil, err
}
// Total appointments
err = models.DB.QueryRow(`
SELECT COUNT(*)
@@ -214,8 +211,12 @@ func getVolunteerStatistics(userID int) (*VolunteerStatistics, error) {
}
// Polls remaining (appointments without poll responses)
stats.PollsRemaining = stats.TotalAppointments - stats.PollsCompleted
fmt.Print(stats.PollsRemaining)
// Calculate completion percentage
if stats.TotalAppointments > 0 {
stats.PollCompletionPercent = (stats.PollsCompleted * 100) / stats.TotalAppointments

View File

@@ -68,9 +68,9 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
utils.Render(w, "poll_form.html", map[string]interface{}{
"Title": "Poll Questions",
"IsAuthenticated": true,
"ShowAdminNav": true,
"ShowVolunteerNav": true,
"ActiveSection": "schedule",
"UserName": username,
"ActiveSection": "appointments",
"PollID": pollID,
"AddressID": addressID,
"Address": address,

View File

@@ -1,34 +1,132 @@
package handlers
import (
"fmt"
"database/sql"
"log"
"net/http"
"time"
"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) {
/////////////////////
// Core Validation //
/////////////////////
// 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")
dateOnly := parsedDate.Format("2006-01-02")
err := models.DB.QueryRow(
`SELECT start_time, end_time
err := models.DB.QueryRow(`
SELECT start_time, end_time
FROM availability
WHERE user_id = $1 AND day = $2`,
userID, dateOnly,
).Scan(&startTime, &endTime)
WHERE user_id = $1 AND day_of_week = $2`,
userID, day).Scan(&startTime, &endTime)
if err != nil {
fmt.Printf("Database query failed: %v\n", err)
if err != sql.ErrNoRows {
log.Printf("DB error in ValidateAvailability: %v", err)
}
return false
}
if assignTime.After(startTime) && assignTime.Before(endTime) {
return true
}else{
return false
return checkTime.After(startTime) && checkTime.Before(endTime)
}
return false
////////////////////
// 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)
}

View 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
}

View File

@@ -1,75 +1,185 @@
{{ define "content" }}
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Toolbar -->
<div class="bg-gray-50 border-b border-gray-200 px-6 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-sm">
<div class="relative">
<i
class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"
></i>
<div class="bg-white border-b border-gray-200 px-4 md:px-6 py-4">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<!-- Search -->
<div class="relative w-full sm:w-auto">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"></i>
<input
type="text"
placeholder="Search Appointments"
class="w-full pl-8 pr-3 py-2 text-sm border border-gray-200 rounded bg-white"
placeholder="Search appointments..."
x-model="search"
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>
<!-- 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 -->
<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>
<!-- 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="divide-y divide-gray-200">
<tbody class="bg-white divide-y divide-gray-100">
{{ range .Appointments }}
<tr class="hover:bg-gray-50">
<td class="px-6 py-3 whitespace-nowrap">
<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 }}"
>
<a href="/poll?address_id={{ .AddressID }}" class="{{ .PollButtonClass }}">
{{ .PollButtonText }}
</a>
{{ end }}
</td>
<td class="px-6 py-3 whitespace-nowrap">
<!-- 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:underline"
class="text-blue-600 hover:text-blue-800 text-sm hover:underline"
>
{{ .Address }}
</a>
</td>
<td class="px-6 py-3 whitespace-nowrap">
({{ .AppointmentDate.Format "2006-01-02" }} @ {{
.AppointmentTime.Format "15:04" }})
<!-- 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="5" class="px-6 py-8 text-center text-gray-500">
No appointments found
</td>
<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>
<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 }}

View File

@@ -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>
<span {{if eq .ActiveSection "dashboard"}}class="font-medium"{{end}}>Dashboard</span>
</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>
<span {{if eq .ActiveSection "address"}}class="font-medium"{{end}}>Assigned Address</span>
</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 }}
<a href="/logout" class="flex items-center px-3 py-2.5 text-sm text-text-secondary hover:bg-gray-50 rounded-md group">

View File

@@ -1,6 +1,5 @@
{{ define "content" }}
<div class="min-h-screen bg-gray-100">
<div class="max-w-2xl mx-auto">
<!-- Create Post Form -->
<div class="bg-white border-b border-gray-200 p-6">
@@ -101,7 +100,9 @@
</div>
</div>
<div class="ml-4">
<p class="text-sm font-semibold text-gray-900">{{.AuthorName}}</p>
<p class="text-sm font-semibold text-gray-900">
{{.AuthorName}}
</p>
<p class="text-xs text-gray-500">
{{.CreatedAt.Format "Jan 2, 2006"}}
</p>
@@ -124,7 +125,8 @@
{{if .Content}}
<div class="px-6 pt-2 pb-4">
<p class="text-gray-900 leading-relaxed">
<span class="font-semibold">{{.AuthorName}}</span> {{.Content}}
<span class="font-semibold">{{.AuthorName}}</span>
{{.Content}}
</p>
</div>
{{end}}
@@ -145,7 +147,9 @@
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>
<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>
@@ -211,7 +215,7 @@
"Size:",
file.size,
"Type:",
file.type
file.type,
);
// Validate file size (10MB max)
@@ -230,7 +234,9 @@
"image/webp",
];
if (!allowedTypes.includes(file.type)) {
alert("Invalid file type. Please select a valid image file.");
alert(
"Invalid file type. Please select a valid image file.",
);
this.value = "";
return;
}
@@ -268,14 +274,14 @@
this.classList.toggle("active");
// Remove dislike active state from sibling
const dislikeBtn = this.parentElement.querySelector(
'[data-reaction="dislike"]'
'[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"]'
'[data-reaction="like"]',
);
likeBtn.classList.remove("active");
}

View File

@@ -1,18 +1,18 @@
{{ define "content" }}
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Toolbar with Report Selection -->
<div class="bg-gray-50 border-b border-gray-200 px-6 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-sm">
<form method="GET" action="/reports" class="flex items-center gap-3">
<!-- Category Dropdown -->
<div class="relative">
<label for="category" class="text-gray-700 font-medium mr-2">Category:</label>
<div class="flex-1 flex flex-col overflow-hidden" x-data="reportsData()">
<!-- Toolbar -->
<div class="bg-white border-b border-gray-200 px-4 md:px-6 py-4">
<div class="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
<!-- Report Selection Form -->
<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 Selection -->
<div class="flex items-center gap-2">
<label for="category" class="text-sm text-gray-600 whitespace-nowrap font-medium">Category:</label>
<select
name="category"
id="category"
onchange="updateReports()"
class="px-3 py-2 text-sm border border-gray-200 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500 min-w-48"
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="users" {{if eq .Category "users"}}selected{{end}}>Users & Teams</option>
@@ -23,13 +23,13 @@
</select>
</div>
<!-- Report Dropdown -->
<div class="relative">
<label for="report" class="text-gray-700 font-medium mr-2">Report:</label>
<!-- 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 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500 min-w-64"
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}}
@@ -42,71 +42,96 @@
<!-- 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"/>
<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>
<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">
<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>
</form>
</div>
<!-- Actions -->
<!-- Actions & Results Count -->
{{if .Result}}
<div class="flex items-center gap-3 text-sm">
<div class="text-gray-600">
<span>{{.Result.Count}} results</span>
<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>
<!-- <button onclick="exportResults()" class="px-3 py-1.5 bg-green-600 text-white hover:bg-green-700 transition-colors">
<i class="fas fa-download mr-1"></i>Export CSV
</button>
<button onclick="printReport()" class="px-3 py-1.5 bg-blue-600 text-white hover:bg-blue-700 transition-colors">
<i class="fas fa-print mr-1"></i>Print
</button> -->
</div>
{{end}}
</div>
</div>
<!-- Main Content -->
<div class="flex-1 overflow-auto">
<div class="flex-1 p-4 md:p-6 overflow-auto">
{{if .Result}}
{{if .Result.Error}}
<div class="p-6">
<div class="bg-red-50 border border-red-200 p-6">
<!-- Error State -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="bg-red-50 border border-red-200 rounded-lg p-6">
<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>
{{else}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<!-- Report Header -->
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<div class="bg-gray-50 border-b border-gray-200 px-6 py-4">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 class="text-xl font-semibold text-gray-900">{{.ReportTitle}}</h2>
<p class="text-sm text-gray-600 mt-1">{{.ReportDescription}}</p>
</div>
<div class="text-sm text-gray-500">Generated: {{.GeneratedAt}}</div>
<div class="text-sm text-gray-500">
<i class="fas fa-clock mr-1"></i>Generated: {{.GeneratedAt}}
</div>
</div>
</div>
<!-- Results Table -->
{{if gt .Result.Count 0}}
<div class="flex-1 overflow-x-auto overflow-y-auto bg-white">
<table class="w-full divide-gray-200 text-sm table-auto">
<thead class="bg-gray-50 sticky top-0">
<tr class="text-left text-gray-700 font-medium border-b border-gray-200">
<!-- 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>
{{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">
{{formatColumnName .}}
</th>
{{end}}
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="bg-white divide-y divide-gray-100">
{{range .Result.Rows}}
<tr class="hover:bg-gray-50">
{{range .}}
<td class="px-6 py-3 text-sm text-gray-900">{{.}}</td>
<td class="px-6 py-4 text-sm text-gray-900">{{.}}</td>
{{end}}
</tr>
{{end}}
@@ -114,15 +139,44 @@
</table>
</div>
<!-- Mobile Cards -->
<div class="lg:hidden">
<div class="space-y-4 p-4">
{{range $rowIndex, $row := .Result.Rows}}
<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">
<div class="flex items-center space-x-2">
<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-3">Summary Statistics</h4>
<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 px-3 py-2">
<div class="text-xs text-gray-500">{{.Label}}</div>
<div class="text-lg font-semibold text-gray-900">{{.Value}}</div>
<div 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>
@@ -130,20 +184,58 @@
{{end}}
{{else}}
<div class="flex-1 flex items-center justify-center">
<p class="text-gray-500">No results match your selected criteria</p>
<!-- No Results -->
<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>
{{end}}
{{else}}
<div class="flex-1 flex items-center justify-center">
<p class="text-gray-600">Select a category and report to generate results</p>
<!-- Initial State -->
<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>
{{end}}
</div>
</div>
<script>
function reportsData() {
return {
// Any Alpine.js data can go here if needed
};
}
const reportDefinitions = {
users: [
{ 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()}`;
// Initialize reports on page load
document.addEventListener("DOMContentLoaded", function() {
updateReports();
});
</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;
}
function printReport() { window.print(); }
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>
document.addEventListener("DOMContentLoaded", updateReports);
</script>
{{ end }}

View File

@@ -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 }}

View File

@@ -1,12 +1,16 @@
{{ define "content" }}
<!-- Main Content -->
<div class="flex-1 flex flex-col overflow-hidden" x-data="volunteerTable()">
<!-- Toolbar -->
<div class="bg-gray-50 border-b border-gray-200 px-6 py-3">
<div class="flex items-center gap-4 text-sm">
<div class="bg-white border-b border-gray-200 px-4 md:px-6 py-4">
<div
class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"
>
<!-- Search & Filters -->
<div
class="flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full sm:w-auto"
>
<!-- Search -->
<div class="flex items-center gap-2">
<div class="relative">
<div class="relative w-full sm:w-auto">
<i
class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"
></i>
@@ -14,17 +18,20 @@
type="text"
x-model="searchTerm"
placeholder="Search volunteers..."
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"
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>
</div>
<!-- Role Filter -->
<div class="flex items-center gap-2">
<label for="roleFilter" class="text-gray-600 font-medium">Role:</label>
<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 bg-white focus:ring-blue-500 focus:border-blue-500 transition-colors"
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>
@@ -36,137 +43,156 @@
<!-- 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"
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="ml-auto">
<span class="text-gray-600 text-sm">
<div class="text-sm text-gray-600 whitespace-nowrap">
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 class="flex-1 p-4 md:p-6 overflow-auto">
<div
class="flex-1 overflow-x-auto overflow-y-auto bg-white border border-gray-100"
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"
<!-- 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"
>
<th class="px-4 py-3 whitespace-nowrap">
<div
class="flex items-center gap-2 cursor-pointer"
@click="sortBy('UserID')"
>
ID <i class="fas" :class="getSortIcon('UserID')"></i>
ID
<i
class="fas"
:class="getSortIcon('UserID')"
></i>
</div>
</th>
<th class="px-6 py-3 whitespace-nowrap">
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
<div
class="flex items-center gap-2 cursor-pointer"
@click="sortBy('FirstName')"
>
First Name <i class="fas" :class="getSortIcon('FirstName')"></i>
Name
<i
class="fas"
:class="getSortIcon('FirstName')"
></i>
</div>
</th>
<th class="px-6 py-3 whitespace-nowrap">
<div
class="flex items-center gap-2 cursor-pointer"
@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>
</th>
<th class="px-6 py-3 whitespace-nowrap">
<div
class="flex items-center gap-2 cursor-pointer"
@click="sortBy('Email')"
>
Email <i class="fas" :class="getSortIcon('Email')"></i>
Contact
<i
class="fas"
:class="getSortIcon('Email')"
></i>
</div>
</th>
<th class="px-6 py-3 whitespace-nowrap">
<div
class="flex items-center gap-2 cursor-pointer"
@click="sortBy('Phone')"
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap"
>
Phone <i class="fas" :class="getSortIcon('Phone')"></i>
</div>
</th>
<th class="px-6 py-3 whitespace-nowrap">
<div
class="flex items-center gap-2 cursor-pointer"
@click="sortBy('RoleID')"
>
Role <i class="fas" :class="getSortIcon('RoleID')"></i>
Role
<i
class="fas"
:class="getSortIcon('RoleID')"
></i>
</div>
</th>
<th class="px-6 py-3 whitespace-nowrap">Actions</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<!-- Table Body -->
<tbody class="divide-y divide-gray-200">
<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-3 whitespace-nowrap"
<td class="px-6 py-4">
<div
class="text-sm font-medium text-gray-900"
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"
></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"
></td>
<td
class="px-6 py-3 whitespace-nowrap"
></div>
<div
class="text-sm text-gray-500"
x-text="volunteer.Phone"
></td>
<td class="px-6 py-3 whitespace-nowrap">
></div>
</td>
<td class="px-6 py-4">
<span
class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800"
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-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
action="/volunteer/delete"
method="POST"
class="inline-block"
>
<input type="hidden" name="id" :value="volunteer.UserID" />
<td class="px-6 py-4">
<div class="flex items-center space-x-2">
<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?')"
class="text-blue-600 hover:text-blue-800 p-1"
@click="editVolunteer(volunteer)"
title="Edit volunteer"
>
Delete
<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>
</form>
</div>
</td>
</tr>
@@ -174,16 +200,269 @@
</tbody>
</table>
<!-- No Results Message -->
<div x-show="filteredVolunteers.length === 0" class="text-center py-12">
<i class="fas fa-search text-gray-400 text-3xl mb-4"></i>
<p class="text-gray-600 text-lg mb-2">No volunteers found</p>
<p class="text-gray-500 text-sm">
Try adjusting your search or filter criteria
<!-- No Results - Desktop -->
<div
x-show="filteredVolunteers.length === 0"
class="px-6 py-8 text-center text-gray-500"
>
No volunteers found
</div>
</div>
<!-- Mobile Cards -->
<div class="lg:hidden">
<div class="space-y-4 p-4">
<template
x-for="volunteer in filteredVolunteers"
:key="volunteer.UserID"
>
<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>
<script
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
@@ -272,14 +551,131 @@
}
},
getRoleBadgeClass(roleId) {
switch (roleId) {
case 1: return 'bg-red-100 text-red-700'; // Admin - Red
case 2: return 'bg-blue-100 text-blue-700'; // Team Leader - Blue
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>
<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 }}

View File

@@ -3,132 +3,88 @@
<!-- Main Content -->
<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">
<!-- Left Column - Posts -->
<div
class="w-full lg:w-1/2 flex flex-col gap-4 sm:gap-6 sticky top-0 self-start h-fit"
>
<!-- Left Column - Stats -->
<div class="w-full lg:w-1/2 flex flex-col gap-6 sticky top-0 self-start h-fit">
<!-- Today's Overview -->
<div class="bg-white border-b border-gray-200" x-data="{ open: true }">
<div
class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer"
@click="open = !open"
>
<h3 class="text-sm font-semibold text-gray-900">
Today's Overview
</h3>
<i
class="fas"
:class="open ? 'fa-chevron-up' : 'fa-chevron-down'"
></i>
<div class="bg-white rounded-lg shadow-sm border border-gray-200" x-data="{ open: true }">
<div class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer border-b border-gray-100" @click="open = !open">
<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 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="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0"
>
<div 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>
</div>
<span class="text-sm text-gray-700">Appointments Today</span>
</div>
<span class="text-lg font-semibold text-gray-900"
>{{ .Statistics.AppointmentsToday }}</span
>
<span class="text-lg font-semibold text-gray-900">{{ .Statistics.AppointmentsToday }}</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0"
>
<div 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>
</div>
<span class="text-sm text-gray-700"
>Appointments Tomorrow</span
>
<span class="text-sm text-gray-700">Appointments Tomorrow</span>
</div>
<span class="text-lg font-semibold text-gray-900"
>{{ .Statistics.AppointmentsTomorrow }}</span
>
<span class="text-lg font-semibold text-gray-900">{{ .Statistics.AppointmentsTomorrow }}</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
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>
<div 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>
</div>
<span class="text-sm text-gray-700">This Week</span>
</div>
<span class="text-lg font-semibold text-gray-900"
>{{ .Statistics.AppointmentsThisWeek }}</span
>
<span class="text-lg font-semibold text-gray-900">{{ .Statistics.AppointmentsThisWeek }}</span>
</div>
</div>
</div>
</div>
<!-- Polling Progress -->
<div class="bg-white border-b border-gray-200" x-data="{ open: true }">
<div
class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer"
@click="open = !open"
>
<h3 class="text-sm font-semibold text-gray-900">
Polling Progress
</h3>
<i
class="fas"
:class="open ? 'fa-chevron-up' : 'fa-chevron-down'"
></i>
<div class="bg-white rounded-lg shadow-sm border border-gray-200" x-data="{ open: true }">
<div class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer border-b border-gray-100" @click="open = !open">
<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 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="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0"
>
<div 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>
</div>
<span class="text-sm text-gray-700">Polls Completed</span>
</div>
<span class="text-lg font-semibold text-green-600"
>{{ .Statistics.PollsCompleted }}</span
>
<span class="text-lg font-semibold text-green-600">{{ .Statistics.PollsCompleted }}</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0"
>
<div 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>
</div>
<span class="text-sm text-gray-700">Polls Remaining</span>
</div>
<span class="text-lg font-semibold text-orange-600"
>{{ .Statistics.PollsRemaining }}</span
>
<span class="text-lg font-semibold text-orange-600">{{ .Statistics.PollsRemaining }}</span>
</div>
<!-- Progress Bar -->
{{ 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">
<span>Progress</span>
<span
>{{ .Statistics.PollsCompleted }}/{{
.Statistics.TotalAppointments }}</span
>
<span>{{ .Statistics.PollsCompleted }}/{{ .Statistics.TotalAppointments }}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-gray-600 h-2 rounded-full transition-all duration-300"
style="width: {{ .Statistics.PollCompletionPercent }}%"
></div>
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: {{ .Statistics.PollCompletionPercent }}%"></div>
</div>
</div>
{{ end }}
@@ -137,27 +93,20 @@
</div>
<!-- Team Members -->
<div class="bg-white border-b border-gray-200" x-data="{ open: true }">
<div
class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer"
@click="open = !open"
>
<div class="bg-white rounded-lg shadow-sm border border-gray-200" x-data="{ open: true }">
<div class="px-4 sm:px-6 py-4 flex justify-between items-center cursor-pointer border-b border-gray-100" @click="open = !open">
<h3 class="text-sm font-semibold text-gray-900">Team Members</h3>
<i
class="fas"
:class="open ? 'fa-chevron-up' : 'fa-chevron-down'"
></i>
<i class="fas" :class="open ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
</div>
<div class="px-4 sm:px-6 pb-4" x-show="open" x-collapse>
<div class="px-4 sm:px-6 py-4" x-show="open" x-collapse>
<div class="space-y-3">
{{ range .Teammates }}
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">
{{ .FullName }} {{ if .IsLead }}
<span class="ml-2 text-xs text-blue-600 font-semibold"
>{{ .Role }}</span
>
{{ .FullName }}
{{ if .IsLead }}
<span class="ml-2 text-xs text-blue-600 font-semibold">{{ .Role }}</span>
{{ else }}
<span class="ml-2 text-xs text-gray-500">{{ .Role }}</span>
{{ end }}
@@ -174,83 +123,50 @@
</div>
</div>
</div>
<!-- Right Column - Statistics -->
<div class="flex-1 lg:flex-none lg:w-1/2 overflow-y-auto pr-2">
{{ if .Posts }}{{range .Posts}}
<!-- Posts Feed -->
<article class="bg-white border-b border-gray-200">
<!-- Right Column - Posts -->
<div class="flex-1 lg:w-1/2 overflow-y-auto pr-2">
{{ if .Posts }}
{{ range .Posts }}
<article class="bg-white rounded-lg shadow-sm border border-gray-200 mb-4">
<!-- Post Header -->
<div class="flex items-center px-6 py-4">
<div class="flex items-center px-6 py-4 border-b border-gray-100">
<div class="flex-shrink-0">
<div
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 }}
</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>
<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'"
/>
<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">
<div class="px-6 pt-3 pb-4">
<p class="text-gray-900 leading-relaxed">
<span class="font-semibold">{{ .AuthorName }}</span> {{ .Content }}
</p>
</div>
{{ end }}
</article>
{{ end }}
{{ else }}
<div class="bg-white p-12 text-center">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 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"
>
<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>
<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>
<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>
</div>
{{ end }}

View 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 }}

View File

@@ -16,35 +16,6 @@ import (
_ "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 {
err := godotenv.Load()
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) {
utils.Render(w, "dashboard.html", map[string]interface{}{
"Title": "Admin Dashboard",
@@ -116,10 +81,11 @@ func HomeHandler(w http.ResponseWriter, r *http.Request) {
"ActiveSection": "dashboard",
})
}
func main() {
models.InitDB()
models.EmailMessage("hellow")
// Static file servers
fs := http.FileServer(http.Dir("static"))
http.Handle("/static/", http.StripPrefix("/static/", fs))
@@ -143,27 +109,28 @@ func main() {
http.HandleFunc("/dashboard", adminMiddleware(handlers.AdminDashboardHandler))
http.HandleFunc("/volunteers", adminMiddleware(handlers.VolunteerHandler))
http.HandleFunc("/volunteer/edit", adminMiddleware(handlers.EditVolunteerHandler))
http.HandleFunc("/team_builder", adminMiddleware(handlers.TeamBuilderHandler))
http.HandleFunc("/team_builder/remove_volunteer", adminMiddleware(handlers.RemoveVolunteerHandler))
http.HandleFunc("/addresses", adminMiddleware(handlers.AddressHandler))
http.HandleFunc("/assign_address", adminMiddleware(handlers.AssignAddressHandler))
http.HandleFunc("/remove_assigned_address", adminMiddleware(handlers.RemoveAssignedAddressHandler))
http.HandleFunc("/addresses/upload-csv", adminMiddleware(handlers.CSVUploadHandler))
http.HandleFunc("/reports", adminMiddleware(handlers.ReportsHandler))
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/stats", handlers.GetValidatedAddressesStatsHandler)
//--- Volunteer-only routes
http.HandleFunc("/volunteer/dashboard", volunteerMiddleware(handlers.VolunteerPostsHandler))
http.HandleFunc("/volunteer/Addresses", volunteerMiddleware(handlers.VolunteerAppointmentHandler))
http.HandleFunc("/schedual", volunteerMiddleware(schedualHandler))
http.HandleFunc("/volunteer/addresses", volunteerMiddleware(handlers.VolunteerAppointmentHandler))
// 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)
http.HandleFunc("/poll", volunteerMiddleware(handlers.PollHandler))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 KiB