feat: added a side bar
This commit is contained in:
@@ -208,7 +208,7 @@ func AddressHandler(w http.ResponseWriter, r *http.Request) {
|
||||
PageNumbers: pageNumbers,
|
||||
}
|
||||
|
||||
utils.Render(w, "address/address.html", map[string]interface{}{
|
||||
utils.Render(w, "address.html", map[string]interface{}{
|
||||
"Title": "Addresses",
|
||||
"IsAuthenticated": true,
|
||||
"ShowAdminNav": true,
|
||||
|
||||
@@ -67,7 +67,7 @@ func AdminDashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
housesLeftPercent = 0 // Set default value on error
|
||||
}
|
||||
|
||||
utils.Render(w, "dashboard/dashboard.html", map[string]interface{}{
|
||||
utils.Render(w, "dashboard.html", map[string]interface{}{
|
||||
"Title": "Admin Dashboard",
|
||||
"IsAuthenticated": true,
|
||||
"VolunteerCount": volunteerCount,
|
||||
|
||||
@@ -75,7 +75,7 @@ func ReportsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"ShowAdminNav": role == 1,
|
||||
"ShowVolunteerNav": role != 1,
|
||||
"UserName": username,
|
||||
"ActiveSection": "reports",
|
||||
"ActiveSection": "reports",
|
||||
"Category": category,
|
||||
"ReportID": reportID,
|
||||
"DateFrom": dateFrom,
|
||||
@@ -177,20 +177,23 @@ func getAllReportDefinitions() map[string][]ReportDefinition {
|
||||
return map[string][]ReportDefinition{
|
||||
"users": {
|
||||
{
|
||||
ID: "users_by_role",
|
||||
Name: "Users by Role",
|
||||
ID: "volunteer_participation_rate", // get all the appointment(done, notdone, total) poll(done, not doen, total)
|
||||
Name: "Volunteer participation rate",
|
||||
Description: "Count of users grouped by their role",
|
||||
SQL: `SELECT
|
||||
CASE
|
||||
WHEN role_id = 1 THEN 'Admin'
|
||||
WHEN role_id = 2 THEN 'Volunteer'
|
||||
ELSE 'Unknown'
|
||||
END as role,
|
||||
COUNT(*) as user_count,
|
||||
COUNT(CASE WHEN created_at >= ?1 THEN 1 END) as new_this_period
|
||||
FROM users
|
||||
GROUP BY role_id
|
||||
ORDER BY role_id`,
|
||||
u.user_id,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
COUNT(p.poll_id) AS total_polls,
|
||||
COUNT(a.user_id) AS total_appointments,
|
||||
case
|
||||
WHEN COUNT(a.user_id) = 0 THEN NULL -- avoid division by zero
|
||||
ELSE ROUND(CAST(COUNT(p.poll_id) AS numeric) / COUNT(a.user_id), 2)
|
||||
END AS poll_to_appointment_rate
|
||||
from users u
|
||||
LEFT JOIN poll p ON u.user_id = p.user_id
|
||||
LEFT JOIN appointment a ON u.user_id = a.user_id
|
||||
GROUP BY u.user_id, u.first_name, u.last_name;`,
|
||||
},
|
||||
{
|
||||
ID: "volunteer_activity",
|
||||
|
||||
@@ -81,7 +81,7 @@ func TeamBuilderHandler(w http.ResponseWriter, r *http.Request) {
|
||||
unassignedVolunteers = append(unassignedVolunteers, vol)
|
||||
}
|
||||
|
||||
utils.Render(w, "volunteer/team_builder.html", map[string]interface{}{
|
||||
utils.Render(w, "team_builder.html", map[string]interface{}{
|
||||
"Title": "Team Builder",
|
||||
"IsAuthenticated": true,
|
||||
"ShowAdminNav": true,
|
||||
|
||||
@@ -39,7 +39,7 @@ func VolunteerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user = append(user, b)
|
||||
}
|
||||
|
||||
utils.Render(w, "volunteer/volunteer.html", map[string]interface{}{
|
||||
utils.Render(w, "volunteer.html", map[string]interface{}{
|
||||
"Title": "Assigned Volunteers",
|
||||
"IsAuthenticated": true,
|
||||
"ShowAdminNav": true,
|
||||
@@ -66,7 +66,7 @@ func EditVolunteerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Render(w, "volunteer/edit_volunteer.html", map[string]interface{}{
|
||||
utils.Render(w, "edit_volunteer.html", map[string]interface{}{
|
||||
"Title": "Edit Volunteer",
|
||||
"IsAuthenticated": true,
|
||||
"ShowAdminNav": true,
|
||||
|
||||
@@ -30,29 +30,12 @@ func getDefaultRedirectURL(role int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to render error pages with consistent data
|
||||
func renderLoginError(w http.ResponseWriter, errorMsg string) {
|
||||
utils.Render(w, "login.html", map[string]interface{}{
|
||||
"Error": errorMsg,
|
||||
"Title": "Login",
|
||||
"IsAuthenticated": false,
|
||||
})
|
||||
}
|
||||
|
||||
func renderRegisterError(w http.ResponseWriter, errorMsg string) {
|
||||
utils.Render(w, "register.html", map[string]interface{}{
|
||||
"Error": errorMsg,
|
||||
"Title": "Register",
|
||||
"IsAuthenticated": false,
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to create and sign JWT token
|
||||
func createJWTToken(userID, role int) (string, time.Time, error) {
|
||||
|
||||
err := godotenv.Load() // or specify path: godotenv.Load("/path/to/.env")
|
||||
if err != nil {
|
||||
log.Fatalf("Error loading .env file: %v", err)
|
||||
log.Fatalf("Error loading .env file: %v", err)
|
||||
}
|
||||
|
||||
// Get individual components from environment variables
|
||||
@@ -60,7 +43,6 @@ func createJWTToken(userID, role int) (string, time.Time, error) {
|
||||
|
||||
var jwtKey = []byte(jwtSecret)
|
||||
|
||||
|
||||
expirationTime := time.Now().Add(12 * time.Hour)
|
||||
claims := &models.Claims{
|
||||
UserID: userID,
|
||||
@@ -113,7 +95,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Input validation
|
||||
if email == "" || password == "" {
|
||||
http.Redirect(w, r, "/?error=EmailAndPasswordRequired", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -130,7 +112,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Login failed for email %s: %v", email, err)
|
||||
http.Redirect(w, r, "/?error=InvalidCredentials", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,7 +120,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
err = bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password))
|
||||
if err != nil {
|
||||
log.Printf("Password verification failed for user ID %d", userID)
|
||||
http.Redirect(w, r, "/?error=InvalidCredentials", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,7 +128,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
tokenString, expirationTime, err := createJWTToken(userID, role)
|
||||
if err != nil {
|
||||
log.Printf("JWT token creation failed for user ID %d: %v", userID, err)
|
||||
http.Redirect(w, r, "/?error=InternalError", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -159,7 +141,6 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
utils.Render(w, "layout.html", map[string]interface{}{
|
||||
@@ -179,7 +160,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Input validation
|
||||
if firstName == "" || lastName == "" || email == "" || password == "" || role == "" {
|
||||
renderRegisterError(w, "All fields are required")
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -187,21 +168,21 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("Password hashing failed: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert role to int
|
||||
roleID, err := strconv.Atoi(role)
|
||||
if err != nil {
|
||||
renderRegisterError(w, "Invalid role")
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var adminID int
|
||||
if roleID == 3 { // volunteer
|
||||
if adminCode == "" {
|
||||
renderRegisterError(w, "Admin code is required for volunteers")
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -209,11 +190,11 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
err = models.DB.QueryRow(`SELECT user_id FROM users WHERE role_id = 1 AND admin_code = $1`, adminCode).Scan(&adminID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
renderRegisterError(w, "Invalid admin code")
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
log.Printf("DB error checking admin code: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -227,7 +208,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
`, firstName, lastName, email, phone, string(hashedPassword), roleID).Scan(&userID)
|
||||
if err != nil {
|
||||
log.Printf("User registration failed: %v", err)
|
||||
renderRegisterError(w, "Could not create account. Email might already be in use.")
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -239,7 +220,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
`, adminID, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to link volunteer to admin: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -248,9 +229,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
|
||||
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
clearSessionCookie(w)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func ProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
volunteernav = true
|
||||
}
|
||||
|
||||
utils.Render(w, "profile/profile.html", map[string]interface{}{
|
||||
utils.Render(w, "profile.html", map[string]interface{}{
|
||||
"Title": "My Profile",
|
||||
"IsAuthenticated": true,
|
||||
"ShowAdminNav": adminnav,
|
||||
|
||||
@@ -98,7 +98,7 @@ func VolunteerAppointmentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Render template
|
||||
utils.Render(w, "/appointment.html", map[string]interface{}{
|
||||
utils.Render(w, "appointment.html", map[string]interface{}{
|
||||
"Title": "My Profile",
|
||||
"IsAuthenticated": true,
|
||||
"ShowAdminNav": adminnav,
|
||||
|
||||
@@ -124,7 +124,7 @@ func VolunteerPostsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
fmt.Printf("Volunteer viewing %d posts\n", len(posts))
|
||||
|
||||
utils.Render(w, "dashboard/volunteer_dashboard.html", map[string]interface{}{
|
||||
utils.Render(w, "volunteer_dashboard.html", map[string]interface{}{
|
||||
"Title": "Volunteer Dashboard",
|
||||
"IsAuthenticated": true,
|
||||
"ShowAdminNav": showAdminNav,
|
||||
|
||||
@@ -65,7 +65,7 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Render(w, "volunteer/poll_form.html", map[string]interface{}{
|
||||
utils.Render(w, "poll_form.html", map[string]interface{}{
|
||||
"Title": "Poll Questions",
|
||||
"IsAuthenticated": true,
|
||||
"ShowAdminNav": true,
|
||||
@@ -120,7 +120,6 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Insert poll response
|
||||
_, err = models.DB.Exec(`
|
||||
INSERT INTO poll_response (
|
||||
@@ -135,6 +134,22 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Print(err)
|
||||
http.Error(w, "Failed to save poll response", http.StatusInternalServerError)
|
||||
return
|
||||
}else{
|
||||
_, err := models.DB.Exec(`
|
||||
UPDATE address_database
|
||||
SET visited_validated = true
|
||||
WHERE address_id IN (
|
||||
SELECT address_id
|
||||
FROM poll
|
||||
WHERE poll_id = $1
|
||||
)
|
||||
`, pollID)
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
http.Error(w, "Failed to save poll response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/volunteer/Addresses", http.StatusSeeOther)
|
||||
|
||||
11
app/internal/handlers/volunteer_schedual.go
Normal file
11
app/internal/handlers/volunteer_schedual.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func VolunteerSchedualHandler(w *http.ResponseWriter, r http.Request) {
|
||||
|
||||
fmt.Print("Not Implementated Yet!!!")
|
||||
}
|
||||
533
app/internal/templates/address.html
Normal file
533
app/internal/templates/address.html
Normal file
@@ -0,0 +1,533 @@
|
||||
{{ 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">
|
||||
<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 Addresses"
|
||||
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 Controls -->
|
||||
{{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">
|
||||
|
||||
|
||||
|
||||
<button
|
||||
class="px-6 py-2.5 bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors"
|
||||
onclick="window.location.href='/addresses/upload-csv'"
|
||||
>
|
||||
<i class="fas fa-file-import mr-2"></i>Import Data
|
||||
</button>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
Status
|
||||
</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 whitespace-nowrap">
|
||||
Coordinates
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||
Assigned User
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||
Appointment
|
||||
</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">
|
||||
{{ range .Addresses }}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4">
|
||||
{{ if .VisitedValidated }}
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||
<i class="fas fa-check mr-1"></i> Valid
|
||||
</span>
|
||||
{{ else }}
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">
|
||||
<i class="fas fa-times mr-1"></i> Invalid
|
||||
</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-gray-900">{{ .Address }}</div>
|
||||
</td>
|
||||
<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"
|
||||
>
|
||||
({{ .Latitude }}, {{ .Longitude }})
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{{ if .UserName }}
|
||||
<div class="text-sm font-medium text-gray-900">{{ .UserName }}</div>
|
||||
<div class="text-sm text-gray-500">{{ .UserEmail }}</div>
|
||||
{{ else }}
|
||||
<span class="text-sm text-gray-400">Unassigned</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{{ if .AppointmentDate }}
|
||||
<div class="text-sm text-gray-900">{{ .AppointmentDate }}</div>
|
||||
<div class="text-sm text-gray-500">{{ .AppointmentTime }}</div>
|
||||
{{ else }}
|
||||
<span class="text-sm text-gray-400">No appointment</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
{{ if .Assigned }}
|
||||
<button
|
||||
class="px-3 py-1 bg-gray-100 text-gray-500 text-sm rounded-md cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
Assigned
|
||||
</button>
|
||||
<form action="/remove_assigned_address" method="POST" class="inline-block">
|
||||
<input type="hidden" name="address_id" value="{{ .AddressID }}" />
|
||||
<input type="hidden" name="user_id" value="{{ .UserID }}" />
|
||||
<button
|
||||
type="submit"
|
||||
class="text-red-400 hover:text-red-600 p-1"
|
||||
title="Remove assignment"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{ else }}
|
||||
<button
|
||||
class="px-3 py-1 bg-blue-500 text-white text-sm rounded-md hover:bg-blue-600 transition-colors"
|
||||
onclick="openAssignModal({{ .AddressID }}, '{{ .Address }}')"
|
||||
>
|
||||
Assign
|
||||
</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-8 text-center text-gray-500">
|
||||
No addresses found
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Cards -->
|
||||
<div class="lg:hidden">
|
||||
<div class="space-y-4 p-4">
|
||||
{{ range .Addresses }}
|
||||
<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-map-marker-alt text-gray-400"></i>
|
||||
<span class="text-sm font-semibold text-gray-900">Address</span>
|
||||
</div>
|
||||
{{ if .VisitedValidated }}
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||
<i class="fas fa-check mr-1"></i> Valid
|
||||
</span>
|
||||
{{ else }}
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">
|
||||
<i class="fas fa-times mr-1"></i> Invalid
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Card Content -->
|
||||
<div class="p-4 space-y-3">
|
||||
<!-- Address -->
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-gray-900">{{ .Address }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Coordinates -->
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-gray-500">Coordinates</span>
|
||||
<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"
|
||||
>
|
||||
({{ .Latitude }}, {{ .Longitude }})
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Assigned User -->
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-sm text-gray-500">Assigned User</span>
|
||||
<div class="text-right">
|
||||
{{ if .UserName }}
|
||||
<div class="text-sm font-medium text-gray-900">{{ .UserName }}</div>
|
||||
<div class="text-sm text-gray-500">{{ .UserEmail }}</div>
|
||||
{{ else }}
|
||||
<span class="text-sm text-gray-400">Unassigned</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appointment -->
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-sm text-gray-500">Appointment</span>
|
||||
<div class="text-right">
|
||||
{{ if .AppointmentDate }}
|
||||
<div class="text-sm text-gray-900">{{ .AppointmentDate }}</div>
|
||||
<div class="text-sm text-gray-500">{{ .AppointmentTime }}</div>
|
||||
{{ else }}
|
||||
<span class="text-sm text-gray-400">No appointment</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-center space-x-4 pt-3 border-t border-gray-100">
|
||||
{{ if .Assigned }}
|
||||
<button
|
||||
class="flex-1 px-4 py-2 bg-gray-100 text-gray-500 text-sm rounded-md cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
Already Assigned
|
||||
</button>
|
||||
<form action="/remove_assigned_address" method="POST" class="inline-block">
|
||||
<input type="hidden" name="address_id" value="{{ .AddressID }}" />
|
||||
<input type="hidden" name="user_id" value="{{ .UserID }}" />
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors text-sm font-medium"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i> Remove
|
||||
</button>
|
||||
</form>
|
||||
{{ else }}
|
||||
<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"
|
||||
onclick="openAssignModal({{ .AddressID }}, '{{ .Address }}')"
|
||||
>
|
||||
<i class="fas fa-user-plus mr-1"></i> Assign User
|
||||
</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="text-center py-12">
|
||||
<div class="text-gray-400 mb-4">
|
||||
<i class="fas fa-map-marker-alt text-4xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No addresses found</h3>
|
||||
<p class="text-gray-500">Try adjusting your search criteria.</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assign Panel Overlay -->
|
||||
<div
|
||||
id="assignPanelOverlay"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 hidden z-40"
|
||||
></div>
|
||||
|
||||
<!-- Assign Drawer Panel -->
|
||||
<div
|
||||
id="assignPanel"
|
||||
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"></i>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Assign Address</h2>
|
||||
</div>
|
||||
<button
|
||||
onclick="closeAssignPanel()"
|
||||
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="assignForm" method="POST" action="/assign_address" class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<input type="hidden" name="address_id" id="panelAddressID" />
|
||||
|
||||
<!-- Selected Address Display -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<i class="fas fa-map-marker-alt text-blue-500"></i>
|
||||
<span class="font-medium text-gray-900">Selected Address:</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700" id="panel-selected-address">None selected</div>
|
||||
</div>
|
||||
|
||||
<!-- User Selection -->
|
||||
<div>
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-user mr-2 text-gray-400"></i>Select User
|
||||
</label>
|
||||
<select
|
||||
name="user_id"
|
||||
id="user_id"
|
||||
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"
|
||||
required
|
||||
>
|
||||
<option value="">-- Select User --</option>
|
||||
{{ range .Users }}
|
||||
<option value="{{ .ID }}">{{ .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date Selection -->
|
||||
<div>
|
||||
<label for="appointment-date" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-calendar mr-2 text-gray-400"></i>Appointment Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="appointment-date"
|
||||
name="appointment_date"
|
||||
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"
|
||||
min=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Time Selection -->
|
||||
<div>
|
||||
<label for="time" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-clock mr-2 text-gray-400"></i>Appointment Time
|
||||
</label>
|
||||
<select
|
||||
id="time"
|
||||
name="time"
|
||||
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 Time</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="closeAssignPanel()"
|
||||
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="assignForm"
|
||||
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> Assign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<script>
|
||||
// Generate time options in 20-minute increments
|
||||
function generateTimeOptions() {
|
||||
const times = [];
|
||||
for (let hour = 8; hour < 18; hour++) { // Business hours 8 AM to 6 PM
|
||||
for (let minute = 0; minute < 60; minute += 20) {
|
||||
const timeString = String(hour).padStart(2, "0") + ":" + String(minute).padStart(2, "0");
|
||||
const displayTime = formatTime12Hour(hour, minute);
|
||||
times.push({ value: timeString, display: displayTime });
|
||||
}
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
// Format time to 12-hour format
|
||||
function formatTime12Hour(hour, minute) {
|
||||
const ampm = hour >= 12 ? "PM" : "AM";
|
||||
const displayHour = hour % 12 || 12;
|
||||
return displayHour + ":" + String(minute).padStart(2, "0") + " " + ampm;
|
||||
}
|
||||
|
||||
// Populate time dropdown
|
||||
function populateTimeSelect() {
|
||||
const timeSelect = document.getElementById("time");
|
||||
const times = generateTimeOptions();
|
||||
|
||||
timeSelect.innerHTML = '<option value="">Select Time</option>';
|
||||
times.forEach((time) => {
|
||||
const option = new Option(time.display, time.value);
|
||||
timeSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function openAssignModal(addressID, address) {
|
||||
document.getElementById("panelAddressID").value = addressID;
|
||||
document.getElementById("panel-selected-address").textContent =
|
||||
address || "Address ID: " + addressID;
|
||||
|
||||
// Set minimum date to today
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
document.getElementById("appointment-date").min = today;
|
||||
document.getElementById("appointment-date").value = today;
|
||||
|
||||
// Show overlay + panel
|
||||
document.getElementById("assignPanelOverlay").classList.remove("hidden");
|
||||
document
|
||||
.getElementById("assignPanel")
|
||||
.classList.remove("translate-x-full");
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById("user_id").focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function closeAssignPanel() {
|
||||
document
|
||||
.getElementById("assignPanel")
|
||||
.classList.add("translate-x-full");
|
||||
document
|
||||
.getElementById("assignPanelOverlay")
|
||||
.classList.add("hidden");
|
||||
|
||||
document.getElementById("assignForm").reset();
|
||||
document.getElementById("panel-selected-address").textContent =
|
||||
"None selected";
|
||||
}
|
||||
|
||||
// Close when clicking overlay
|
||||
document
|
||||
.getElementById("assignPanelOverlay")
|
||||
.addEventListener("click", closeAssignPanel);
|
||||
|
||||
// Close on Escape key
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape") {
|
||||
closeAssignPanel();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Initialize when page loads
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
populateTimeSelect();
|
||||
|
||||
// Close panel when clicking outside
|
||||
document.getElementById("assignPanelOverlay").addEventListener("click", function (e) {
|
||||
closeAssignPanel();
|
||||
});
|
||||
|
||||
// Close panel on Escape key
|
||||
document.addEventListener("keydown", function(e) {
|
||||
if (e.key === "Escape") {
|
||||
const overlay = document.getElementById("assignPanelOverlay");
|
||||
if (!overlay.classList.contains("invisible")) {
|
||||
closeAssignPanel();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{{ end }}
|
||||
@@ -1,401 +0,0 @@
|
||||
{{ 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>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Addresses"
|
||||
class="w-full pl-8 pr-3 py-2 text-sm border border-gray-200 rounded bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Pagination}}
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="pageSize" class="text-gray-600">Per page:</label>
|
||||
<select
|
||||
id="pageSize"
|
||||
onchange="changePageSize(this.value)"
|
||||
class="px-3 py-1 text-sm border border-gray-200 rounded bg-white"
|
||||
>
|
||||
<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-1 text-sm border border-gray-200 rounded {{if .Pagination.HasPrevious}}hover:bg-gray-50 text-gray-700{{else}}text-gray-400 cursor-not-allowed{{end}}"
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<span class="px-2 text-gray-600"
|
||||
>{{.Pagination.CurrentPage}} / {{.Pagination.TotalPages}}</span
|
||||
>
|
||||
<button
|
||||
onclick="goToPage({{.Pagination.NextPage}})"
|
||||
{{if
|
||||
not
|
||||
.Pagination.HasNext}}disabled{{end}}
|
||||
class="px-3 py-1 text-sm border border-gray-200 rounded {{if .Pagination.HasNext}}hover:bg-gray-50 text-gray-700{{else}}text-gray-400 cursor-not-allowed{{end}}"
|
||||
>
|
||||
<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">Validated</th>
|
||||
<th class="px-6 py-3 whitespace-nowrap">Address</th>
|
||||
<th class="px-6 py-3 whitespace-nowrap">Coordinates</th>
|
||||
<th class="px-6 py-3 whitespace-nowrap">Assigned User</th>
|
||||
<th class="px-6 py-3 whitespace-nowrap">Appointment</th>
|
||||
<th class="px-6 py-3 whitespace-nowrap">Assign</th>
|
||||
<th class="px-6 py-3 whitespace-nowrap">Remove</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{{ range .Addresses }}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
{{ if .VisitedValidated }}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full"
|
||||
>
|
||||
<i class="fas fa-check mr-1"></i> Valid
|
||||
</span>
|
||||
{{ else }}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full"
|
||||
>
|
||||
<i class="fas fa-times mr-1"></i> Invalid
|
||||
</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap">{{ .Address }}</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"
|
||||
>
|
||||
({{ .Latitude }}, {{ .Longitude }})
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
{{ if .UserName }}{{ .UserName }}<br /><span
|
||||
class="text-xs text-gray-500"
|
||||
>{{ .UserEmail }}</span
|
||||
>{{ else }}<span class="text-gray-400">Unassigned</span>{{ end }}
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
{{ if .AppointmentDate }} {{ .AppointmentDate }} {{ .AppointmentTime
|
||||
}} {{ else }}
|
||||
<span class="text-gray-400">No appointment</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
{{ if .Assigned }}
|
||||
<button
|
||||
class="px-3 py-1 bg-gray-400 text-white text-sm cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
Assigned
|
||||
</button>
|
||||
{{ else }}
|
||||
<button
|
||||
class="px-3 py-1 bg-blue-600 text-white text-sm hover:bg-blue-700"
|
||||
onclick="openAssignModal({{ .AddressID }}, '{{ .Address }}')"
|
||||
>
|
||||
Assign
|
||||
</button>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
{{ if .Assigned }}
|
||||
<form
|
||||
action="/remove_assigned_address"
|
||||
method="POST"
|
||||
class="inline-block"
|
||||
>
|
||||
<input type="hidden" name="address_id" value="{{ .AddressID }}" />
|
||||
<input type="hidden" name="user_id" value="{{ .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"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
{{ else }}
|
||||
<span class="text-gray-400 text-xs">-</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-8 text-center text-gray-500">
|
||||
No addresses found
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Assign Modal -->
|
||||
<div
|
||||
id="assignModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white w-full max-w-lg mx-4 shadow-lg">
|
||||
<!-- Modal Header -->
|
||||
<div
|
||||
class="flex justify-between items-center px-6 py-4 border-b border-gray-200"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Assign Address</h2>
|
||||
<button
|
||||
onclick="closeAssignModal()"
|
||||
class="text-gray-400 hover:text-gray-600 focus:outline-none"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<form
|
||||
id="assignForm"
|
||||
method="POST"
|
||||
action="/assign_address"
|
||||
class="p-6 space-y-4"
|
||||
>
|
||||
<input type="hidden" name="address_id" id="modalAddressID" />
|
||||
|
||||
<!-- Selected Address Display -->
|
||||
<div class="bg-gray-50 p-3 border border-gray-200">
|
||||
<div class="flex items-center space-x-2 mb-4">
|
||||
<i class="fas fa-map-marker-alt text-gray-500"></i>
|
||||
<span class="font-medium text-gray-900">Selected Address:</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 mb-4" id="selected-address">
|
||||
None selected
|
||||
</div>
|
||||
|
||||
<!-- User Selection -->
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="user_id"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
<i class="fas fa-user mr-2"></i>Select User
|
||||
</label>
|
||||
<select
|
||||
name="user_id"
|
||||
id="user_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="">-- Select User --</option>
|
||||
{{ range .Users }}
|
||||
<option value="{{ .ID }}">{{ .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date Selection -->
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="appointment-date"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
<i class="fas fa-calendar mr-2"></i>Appointment Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="appointment-date"
|
||||
name="appointment_date"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
min=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Time Selection -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Start Time -->
|
||||
<div>
|
||||
<label
|
||||
for="time"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
<i class="fas fa-clock mr-2"></i>Time
|
||||
</label>
|
||||
<select
|
||||
id="time"
|
||||
name="time"
|
||||
required
|
||||
onchange="updateEndTime()"
|
||||
class="w-full px-3 py-2 border border-gray-300 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Actions -->
|
||||
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onclick="closeAssignModal()"
|
||||
class="px-4 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 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
|
||||
>
|
||||
<i class="fas fa-check mr-2"></i> Assign
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Square corners across UI */
|
||||
* {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
button {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Generate time options in 10-minute increments
|
||||
function generateTimeOptions() {
|
||||
const times = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 20) {
|
||||
const timeString =
|
||||
String(hour).padStart(2, "0") + ":" + String(minute).padStart(2, "0");
|
||||
const displayTime = formatTime12Hour(hour, minute);
|
||||
times.push({ value: timeString, display: displayTime });
|
||||
}
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
// Format time to 12-hour format
|
||||
function formatTime12Hour(hour, minute) {
|
||||
const ampm = hour >= 12 ? "PM" : "AM";
|
||||
const displayHour = hour % 12 || 12;
|
||||
return displayHour + ":" + String(minute).padStart(2, "0") + " " + ampm;
|
||||
}
|
||||
|
||||
// Populate time dropdown
|
||||
function populateTimeSelect() {
|
||||
const timeSelect = document.getElementById("time");
|
||||
const times = generateTimeOptions();
|
||||
|
||||
timeSelect.innerHTML = '<option value="">Select Time</option>';
|
||||
times.forEach((time) => {
|
||||
const option = new Option(time.display, time.value);
|
||||
timeSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function openAssignModal(addressID, address) {
|
||||
document.getElementById("modalAddressID").value = addressID;
|
||||
document.getElementById("selected-address").textContent =
|
||||
address || "Address ID: " + addressID;
|
||||
|
||||
// Set minimum date to today
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
document.getElementById("appointment-date").min = today;
|
||||
document.getElementById("appointment-date").value = today;
|
||||
|
||||
document.getElementById("assignModal").classList.remove("hidden");
|
||||
document.getElementById("assignModal").classList.add("flex");
|
||||
}
|
||||
|
||||
function closeAssignModal() {
|
||||
document.getElementById("assignModal").classList.remove("flex");
|
||||
document.getElementById("assignModal").classList.add("hidden");
|
||||
document.getElementById("assignForm").reset();
|
||||
document.getElementById("selected-address").textContent = "None selected";
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Initialize when page loads
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
populateTimeSelect();
|
||||
|
||||
// Close modal when clicking outside
|
||||
document
|
||||
.getElementById("assignModal")
|
||||
.addEventListener("click", function (e) {
|
||||
if (e.target === this) {
|
||||
closeAssignModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{{ end }}
|
||||
@@ -18,10 +18,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/dashboard"
|
||||
href="/addresses"
|
||||
class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded inline-flex items-center"
|
||||
>
|
||||
<i class="fas fa-arrow-left mr-2"></i>Back to Dashboard
|
||||
<i class="fas fa-arrow-left mr-2"></i>Back to Addresses
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
387
app/internal/templates/dashboard.html
Normal file
387
app/internal/templates/dashboard.html
Normal file
@@ -0,0 +1,387 @@
|
||||
{{ define "content" }}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/ol@7.5.2/ol.css"
|
||||
/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/ol@7.5.2/dist/ol.js"></script>
|
||||
|
||||
<style>
|
||||
#single-map {
|
||||
width: 100%;
|
||||
height: calc(100vh - 80px); /* Account for header height */
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Hide map controls when sidebar is active on mobile */
|
||||
@media (max-width: 768px) {
|
||||
#single-map {
|
||||
height: 50vh; /* Smaller height on mobile */
|
||||
}
|
||||
|
||||
body.sidebar-open .map-controls {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.control-button {
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.control-button:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.ol-popup {
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
bottom: 12px;
|
||||
left: -50px;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.ol-popup:after {
|
||||
top: 100%;
|
||||
border: solid transparent;
|
||||
content: " ";
|
||||
height: 0;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border-top-color: #ffffff;
|
||||
border-width: 10px;
|
||||
left: 48px;
|
||||
margin-left: -10px;
|
||||
}
|
||||
|
||||
/* Ensure OpenLayers controls stay below sidebar */
|
||||
.ol-control {
|
||||
z-index: 150 !important;
|
||||
}
|
||||
|
||||
/* Hide popup when sidebar is open on mobile */
|
||||
@media (max-width: 768px) {
|
||||
body.sidebar-open .ol-popup {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dashboard layout */
|
||||
.dashboard-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 80px); /* Account for header */
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.dashboard-container {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#single-map {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.map-section {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.stats-section {
|
||||
width: 20rem;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Dashboard Layout -->
|
||||
<div class="dashboard-container">
|
||||
<!-- Left: Map -->
|
||||
<div class="map-section bg-white">
|
||||
<div class="map-controls">
|
||||
<button class="control-button" onclick="refreshMap()" title="Refresh Map">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
<button class="control-button" onclick="fitAllMarkers()" title="Fit All Markers">
|
||||
<i class="fas fa-expand-arrows-alt"></i>
|
||||
</button>
|
||||
<button class="control-button" onclick="clearAllMarkers()" title="Clear All Markers">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="single-map"></div>
|
||||
|
||||
<div id="popup" class="ol-popup">
|
||||
<a
|
||||
href="#"
|
||||
id="popup-closer"
|
||||
class="absolute top-1 right-2 text-gray-500 hover:text-gray-800"
|
||||
>×</a
|
||||
>
|
||||
<div id="popup-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Stats -->
|
||||
<div class="stats-section">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4 flex items-center">
|
||||
<div class="w-10 h-10 bg-blue-50 flex items-center justify-center rounded">
|
||||
<i class="fas fa-users text-blue-600 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600">Active Volunteers</p>
|
||||
<p class="text-xl font-bold text-gray-900">{{.VolunteerCount}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4 flex items-center">
|
||||
<div class="w-10 h-10 bg-green-50 flex items-center justify-center rounded">
|
||||
<i class="fas fa-map-marker-alt text-green-600 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600">Addresses Visited</p>
|
||||
<p class="text-xl font-bold text-gray-900">{{.ValidatedCount}}</p>
|
||||
<p id="marker-count" class="text-xs text-gray-500">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4 flex items-center">
|
||||
<div class="w-10 h-10 bg-yellow-50 flex items-center justify-center rounded">
|
||||
<i class="fas fa-dollar-sign text-yellow-600 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600">Donation</p>
|
||||
<p class="text-xl font-bold text-gray-900">${{.TotalDonations}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4 flex items-center">
|
||||
<div class="w-10 h-10 bg-red-50 flex items-center justify-center rounded">
|
||||
<i class="fas fa-percentage text-red-600 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600">Houses Left</p>
|
||||
<p class="text-xl font-bold text-gray-900">{{.HousesLeftPercent}}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Global variables - only one set
|
||||
let theMap = null;
|
||||
let markerLayer = null;
|
||||
let popup = null;
|
||||
let initialized = false;
|
||||
|
||||
// Clean initialization
|
||||
function initializeMap() {
|
||||
if (initialized || !window.ol) {
|
||||
console.log("Map already initialized or OpenLayers not ready");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Initializing single map...");
|
||||
|
||||
try {
|
||||
// Calgary coordinates
|
||||
const center = ol.proj.fromLonLat([-114.0719, 51.0447]);
|
||||
|
||||
// Create the ONE AND ONLY map
|
||||
theMap = new ol.Map({
|
||||
target: "single-map",
|
||||
layers: [
|
||||
new ol.layer.Tile({
|
||||
source: new ol.source.OSM(),
|
||||
}),
|
||||
],
|
||||
view: new ol.View({
|
||||
center: center,
|
||||
zoom: 11,
|
||||
}),
|
||||
});
|
||||
|
||||
// Create popup
|
||||
popup = new ol.Overlay({
|
||||
element: document.getElementById("popup"),
|
||||
positioning: "bottom-center",
|
||||
stopEvent: false,
|
||||
offset: [0, -50],
|
||||
});
|
||||
theMap.addOverlay(popup);
|
||||
|
||||
// Close popup handler
|
||||
document.getElementById("popup-closer").onclick = function () {
|
||||
popup.setPosition(undefined);
|
||||
return false;
|
||||
};
|
||||
|
||||
// Create marker layer
|
||||
markerLayer = new ol.layer.Vector({
|
||||
source: new ol.source.Vector(),
|
||||
style: new ol.style.Style({
|
||||
text: new ol.style.Text({
|
||||
text: "📍",
|
||||
font: "24px sans-serif",
|
||||
fill: new ol.style.Fill({ color: "#EF4444" }),
|
||||
offsetY: -12,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
theMap.addLayer(markerLayer);
|
||||
|
||||
// Click handler
|
||||
theMap.on("click", function (event) {
|
||||
const feature = theMap.forEachFeatureAtPixel(
|
||||
event.pixel,
|
||||
function (feature) {
|
||||
return feature;
|
||||
}
|
||||
);
|
||||
|
||||
if (feature && feature.get("address_data")) {
|
||||
const data = feature.get("address_data");
|
||||
document.getElementById("popup-content").innerHTML = `
|
||||
<div class="text-sm">
|
||||
<h4 class="font-semibold text-gray-900 mb-2">Address Details</h4>
|
||||
<p><strong>Address:</strong> ${data.address}</p>
|
||||
<p><strong>House #:</strong> ${data.house_number}</p>
|
||||
<p><strong>Street:</strong> ${data.street_name} ${data.street_type}</p>
|
||||
<p><strong>ID:</strong> ${data.address_id}</p>
|
||||
</div>
|
||||
`;
|
||||
popup.setPosition(event.coordinate);
|
||||
} else {
|
||||
popup.setPosition(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
initialized = true;
|
||||
console.log("Map initialized successfully");
|
||||
|
||||
// Load markers
|
||||
setTimeout(loadMarkers, 500);
|
||||
} catch (error) {
|
||||
console.error("Map initialization error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load validated addresses
|
||||
async function loadMarkers() {
|
||||
try {
|
||||
const response = await fetch("/api/validated-addresses");
|
||||
const addresses = await response.json();
|
||||
|
||||
console.log(`Loading ${addresses.length} addresses`);
|
||||
document.getElementById("marker-count").textContent = `${addresses.length} on map`;
|
||||
|
||||
// Clear existing markers
|
||||
markerLayer.getSource().clear();
|
||||
|
||||
// Add new markers
|
||||
const features = [];
|
||||
addresses.forEach((addr) => {
|
||||
if (addr.longitude && addr.latitude) {
|
||||
const coords = ol.proj.fromLonLat([addr.longitude, addr.latitude]);
|
||||
const feature = new ol.Feature({
|
||||
geometry: new ol.geom.Point(coords),
|
||||
address_data: addr,
|
||||
});
|
||||
features.push(feature);
|
||||
}
|
||||
});
|
||||
|
||||
markerLayer.getSource().addFeatures(features);
|
||||
|
||||
if (features.length > 0) {
|
||||
const extent = markerLayer.getSource().getExtent();
|
||||
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading markers:", error);
|
||||
document.getElementById("marker-count").textContent = "Error loading";
|
||||
}
|
||||
}
|
||||
|
||||
// Control functions
|
||||
function refreshMap() {
|
||||
loadMarkers();
|
||||
}
|
||||
|
||||
function fitAllMarkers() {
|
||||
if (markerLayer && markerLayer.getSource().getFeatures().length > 0) {
|
||||
const extent = markerLayer.getSource().getExtent();
|
||||
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] });
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllMarkers() {
|
||||
if (markerLayer) {
|
||||
markerLayer.getSource().clear();
|
||||
}
|
||||
if (popup) {
|
||||
popup.setPosition(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when ready
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
setTimeout(initializeMap, 1000);
|
||||
});
|
||||
|
||||
// Listen for sidebar state changes to manage map controls visibility
|
||||
function handleSidebarToggle() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const body = document.body;
|
||||
|
||||
if (sidebar && sidebar.classList.contains('active')) {
|
||||
body.classList.add('sidebar-open');
|
||||
} else {
|
||||
body.classList.remove('sidebar-open');
|
||||
}
|
||||
}
|
||||
|
||||
// Override the original toggleSidebar function to handle map controls
|
||||
if (typeof window.toggleSidebar === 'function') {
|
||||
const originalToggleSidebar = window.toggleSidebar;
|
||||
window.toggleSidebar = function() {
|
||||
originalToggleSidebar();
|
||||
setTimeout(handleSidebarToggle, 50);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
@@ -1,374 +0,0 @@
|
||||
{{ define "content" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{.Title}}</title>
|
||||
<link
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/ol@7.5.2/ol.css"
|
||||
/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/ol@7.5.2/dist/ol.js"></script>
|
||||
|
||||
<style>
|
||||
/* CRITICAL: Prevent any duplicate maps */
|
||||
.ol-viewport {
|
||||
max-width: 100% !important;
|
||||
max-height: 700px !important;
|
||||
}
|
||||
|
||||
#single-map {
|
||||
width: 100%;
|
||||
height: 700px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.ol-popup {
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
bottom: 12px;
|
||||
left: -50px;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.ol-popup:after {
|
||||
top: 100%;
|
||||
border: solid transparent;
|
||||
content: " ";
|
||||
height: 0;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border-color: rgba(255, 255, 255, 0);
|
||||
border-top-color: #ffffff;
|
||||
border-width: 10px;
|
||||
left: 48px;
|
||||
margin-left: -10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<!-- Navigation -->
|
||||
<div class="bg-white border-b border-gray-200 w-full">
|
||||
<div class="px-8 py-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-blue-600 flex items-center justify-center">
|
||||
<i class="fas fa-chart-bar text-white text-sm"></i>
|
||||
</div>
|
||||
<span class="text-xl font-semibold text-gray-900"
|
||||
>Dashboard Overview</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
class="px-6 py-2.5 bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
onclick="refreshMap()"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-2"></i>Refresh Map
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-2.5 bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors"
|
||||
onclick="window.location.href='/addresses/upload-csv'"
|
||||
>
|
||||
<i class="fas fa-upload mr-2"></i>Import Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 bg-white border-b border-gray-200"
|
||||
>
|
||||
<div class="border-r border-gray-200 p-8">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-blue-50 flex items-center justify-center">
|
||||
<i class="fas fa-users text-blue-600 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">
|
||||
Active Volunteers
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{.VolunteerCount}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-r border-gray-200 p-8">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-green-50 flex items-center justify-center">
|
||||
<i class="fas fa-map-marker-alt text-green-600 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">
|
||||
Addresses Visited
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{.ValidatedCount}}</p>
|
||||
<p id="marker-count" class="text-xs text-gray-500">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-r border-gray-200 p-8">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-yellow-50 flex items-center justify-center">
|
||||
<i class="fas fa-dollar-sign text-yellow-600 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">Donation</p>
|
||||
<p class="text-2xl font-bold text-gray-900">${{.TotalDonations}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-8">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-red-50 flex items-center justify-center">
|
||||
<i class="fas fa-percentage text-red-600 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">Houses Left</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{.HousesLeftPercent}}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SINGLE MAP SECTION -->
|
||||
<div class="bg-white w-full relative">
|
||||
<div class="map-controls">
|
||||
<button class="control-button" onclick="refreshMap()">
|
||||
<i class="fas fa-sync-alt"></i> Refresh
|
||||
</button>
|
||||
<button class="control-button" onclick="fitAllMarkers()">
|
||||
<i class="fas fa-expand-arrows-alt"></i> Fit All
|
||||
</button>
|
||||
<button class="control-button" onclick="clearAllMarkers()">
|
||||
<i class="fas fa-trash"></i> Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- THIS IS THE ONLY MAP CONTAINER -->
|
||||
<div id="single-map"></div>
|
||||
|
||||
<div id="popup" class="ol-popup">
|
||||
<a
|
||||
href="#"
|
||||
id="popup-closer"
|
||||
style="
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
text-decoration: none;
|
||||
"
|
||||
>×</a
|
||||
>
|
||||
<div id="popup-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Global variables - only one set
|
||||
let theMap = null;
|
||||
let markerLayer = null;
|
||||
let popup = null;
|
||||
let initialized = false;
|
||||
|
||||
// Clean initialization
|
||||
function initializeMap() {
|
||||
if (initialized || !window.ol) {
|
||||
console.log("Map already initialized or OpenLayers not ready");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Initializing single map...");
|
||||
|
||||
try {
|
||||
// Calgary coordinates
|
||||
const center = ol.proj.fromLonLat([-114.0719, 51.0447]);
|
||||
|
||||
// Create the ONE AND ONLY map
|
||||
theMap = new ol.Map({
|
||||
target: "single-map",
|
||||
layers: [
|
||||
new ol.layer.Tile({
|
||||
source: new ol.source.OSM(),
|
||||
}),
|
||||
],
|
||||
view: new ol.View({
|
||||
center: center,
|
||||
zoom: 11,
|
||||
}),
|
||||
});
|
||||
|
||||
// Create popup
|
||||
popup = new ol.Overlay({
|
||||
element: document.getElementById("popup"),
|
||||
positioning: "bottom-center",
|
||||
stopEvent: false,
|
||||
offset: [0, -50],
|
||||
});
|
||||
theMap.addOverlay(popup);
|
||||
|
||||
// Close popup handler
|
||||
document.getElementById("popup-closer").onclick = function () {
|
||||
popup.setPosition(undefined);
|
||||
return false;
|
||||
};
|
||||
|
||||
// Create marker layer
|
||||
markerLayer = new ol.layer.Vector({
|
||||
source: new ol.source.Vector(),
|
||||
style: new ol.style.Style({
|
||||
text: new ol.style.Text({
|
||||
text: "📍",
|
||||
font: "24px sans-serif",
|
||||
fill: new ol.style.Fill({ color: "#EF4444" }),
|
||||
offsetY: -12, // Adjust vertical position so pin points to location
|
||||
}),
|
||||
}),
|
||||
});
|
||||
theMap.addLayer(markerLayer);
|
||||
|
||||
// Click handler
|
||||
theMap.on("click", function (event) {
|
||||
const feature = theMap.forEachFeatureAtPixel(
|
||||
event.pixel,
|
||||
function (feature) {
|
||||
return feature;
|
||||
}
|
||||
);
|
||||
|
||||
if (feature && feature.get("address_data")) {
|
||||
const data = feature.get("address_data");
|
||||
document.getElementById("popup-content").innerHTML = `
|
||||
<div class="text-sm">
|
||||
<h4 class="font-semibold text-gray-900 mb-2">Address Details</h4>
|
||||
<p><strong>Address:</strong> ${data.address}</p>
|
||||
<p><strong>House #:</strong> ${data.house_number}</p>
|
||||
<p><strong>Street:</strong> ${data.street_name} ${data.street_type}</p>
|
||||
<p><strong>ID:</strong> ${data.address_id}</p>
|
||||
</div>
|
||||
`;
|
||||
popup.setPosition(event.coordinate);
|
||||
} else {
|
||||
popup.setPosition(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
initialized = true;
|
||||
console.log("Map initialized successfully");
|
||||
|
||||
// Load markers
|
||||
setTimeout(loadMarkers, 500);
|
||||
} catch (error) {
|
||||
console.error("Map initialization error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load validated addresses
|
||||
async function loadMarkers() {
|
||||
try {
|
||||
const response = await fetch("/api/validated-addresses");
|
||||
const addresses = await response.json();
|
||||
|
||||
console.log(`Loading ${addresses.length} addresses`);
|
||||
document.getElementById(
|
||||
"marker-count"
|
||||
).textContent = `${addresses.length} on map`;
|
||||
|
||||
// Clear existing markers
|
||||
markerLayer.getSource().clear();
|
||||
|
||||
// Add new markers
|
||||
const features = [];
|
||||
addresses.forEach((addr) => {
|
||||
if (addr.longitude && addr.latitude) {
|
||||
const coords = ol.proj.fromLonLat([
|
||||
addr.longitude,
|
||||
addr.latitude,
|
||||
]);
|
||||
const feature = new ol.Feature({
|
||||
geometry: new ol.geom.Point(coords),
|
||||
address_data: addr,
|
||||
});
|
||||
features.push(feature);
|
||||
}
|
||||
});
|
||||
|
||||
markerLayer.getSource().addFeatures(features);
|
||||
|
||||
if (features.length > 0) {
|
||||
const extent = markerLayer.getSource().getExtent();
|
||||
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading markers:", error);
|
||||
document.getElementById("marker-count").textContent = "Error loading";
|
||||
}
|
||||
}
|
||||
|
||||
// Control functions
|
||||
function refreshMap() {
|
||||
loadMarkers();
|
||||
}
|
||||
|
||||
function fitAllMarkers() {
|
||||
if (markerLayer && markerLayer.getSource().getFeatures().length > 0) {
|
||||
const extent = markerLayer.getSource().getExtent();
|
||||
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] });
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllMarkers() {
|
||||
if (markerLayer) {
|
||||
markerLayer.getSource().clear();
|
||||
}
|
||||
if (popup) {
|
||||
popup.setPosition(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when ready
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
setTimeout(initializeMap, 1000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
@@ -9,615 +9,479 @@
|
||||
<title>{{if .Title}}{{.Title}}{{else}}Poll System{{end}}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="//unpkg.com/alpinejs" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
||||
/>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"custom-gray": "#f8fafc",
|
||||
"sidebar-gray": "#ffffff",
|
||||
"border-gray": "#e2e8f0",
|
||||
"text-primary": "#1e293b",
|
||||
"text-secondary": "#64748b",
|
||||
"blue-primary": "#3b82f6",
|
||||
"blue-light": "#eff6ff",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Mobile sidebar overlay */
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.sidebar-overlay.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Desktop layout - sidebar always visible */
|
||||
@media (min-width: 768px) {
|
||||
.main-content-container {
|
||||
margin-left: 240px; /* Match sidebar width */
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile sidebar positioning */
|
||||
@media (max-width: 767px) {
|
||||
#sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
z-index: 50;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
#sidebar.active {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content-container {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure sidebar has fixed positioning on desktop */
|
||||
@media (min-width: 768px) {
|
||||
#sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
z-index: 30;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbars but keep functionality */
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 font-sans">
|
||||
<body class="bg-custom-gray">
|
||||
{{ if .IsAuthenticated }}
|
||||
<!-- Authenticated User Interface -->
|
||||
<div class="min-h-screen" x-data="{ mobileMenuOpen: false }">
|
||||
<div class="min-h-screen">
|
||||
|
||||
<!-- Simple Navigation Bar -->
|
||||
<nav class="bg-gray-700 border-b border-gray-600 fixed top-0 left-0 w-full z-50">
|
||||
<div class="max-w-9xl mx-auto px-4 sm:px-6 lg:px-8 ">
|
||||
<div class="flex justify-between items-center h-14 ">
|
||||
|
||||
<!-- Left: Logo and Navigation Links -->
|
||||
<div class="flex items-center space-x-12">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<img src="../../static/icon-512.png" alt="Logo" class="w-6 h-6"/>
|
||||
<span class="text-xl font-semibold text-white">Poll System</span>
|
||||
</div>
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<div id="sidebar-overlay" class="sidebar-overlay" onclick="toggleSidebar()"></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div id="sidebar" class="w-60 bg-sidebar-gray border-r border-border-gray flex flex-col">
|
||||
<!-- Logo/Header -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-border-gray">
|
||||
<div class="flex items-center">
|
||||
<div class="w-7 h-7 bg-blue-primary rounded-full flex items-center justify-center mr-3">
|
||||
<img src="../../static/icon-512.png" alt="Logo" class="w-4 h-4"/>
|
||||
</div>
|
||||
<span class="font-semibold text-text-primary text-base">Poll System</span>
|
||||
</div>
|
||||
<!-- Mobile close button -->
|
||||
<button id="sidebar-close" class="md:hidden text-text-secondary hover:text-text-primary" onclick="toggleSidebar()">
|
||||
<i class="fas fa-times text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation Links -->
|
||||
<div class="hidden md:flex items-center space-x-10">
|
||||
{{ if .ShowAdminNav }}
|
||||
<a href="/dashboard" class="text-sm font-medium {{if eq .ActiveSection "dashboard"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/volunteers" class="text-sm font-medium {{if eq .ActiveSection "volunteer"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
||||
Volunteers
|
||||
</a>
|
||||
<a href="/team_builder" class="text-sm font-medium {{if eq .ActiveSection "team_builder"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
||||
Team Builder
|
||||
</a>
|
||||
<a href="/addresses" class="text-sm font-medium {{if eq .ActiveSection "address"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
||||
Addresses
|
||||
</a>
|
||||
<a href="/posts" class="text-sm font-medium {{if eq .ActiveSection "post"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
||||
Posts
|
||||
</a>
|
||||
<a href="/reports" class="text-sm font-medium {{if eq .ActiveSection "report"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
||||
Reports
|
||||
</a>
|
||||
{{ end }}
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 py-4">
|
||||
<div class="space-y-1 px-3">
|
||||
{{ if .ShowAdminNav }}
|
||||
<a href="/dashboard" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "dashboard"}}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-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="/volunteers" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "volunteer"}}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-users w-5 {{if eq .ActiveSection "volunteer"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||
<span {{if eq .ActiveSection "volunteer"}}class="font-medium"{{end}}>Volunteers</span>
|
||||
</a>
|
||||
<a href="/team_builder" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "team_builder"}}bg-blue-light text-blue-primary border-r-4 border-blue-primary pl-2 rounded-none{{else}}text-text-secondary hover:bg-gray-50 rounded-md{{end}} group">
|
||||
<i class="fas fa-user-friends w-5 {{if eq .ActiveSection "team_builder"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||
<span {{if eq .ActiveSection "team_builder"}}class="font-medium"{{end}}>Team Builder</span>
|
||||
</a>
|
||||
<a href="/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-map-marked-alt 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}}>Addresses</span>
|
||||
</a>
|
||||
<a href="/posts" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "posts"}}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-list w-5 {{if eq .ActiveSection "posts"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||
<span {{if eq .ActiveSection "posts"}}class="font-medium"{{end}}>Posts</span>
|
||||
</a>
|
||||
<a href="/reports" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "reports"}}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-table w-5 {{if eq .ActiveSection "reports"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||
<span {{if eq .ActiveSection "reports"}}class="font-medium"{{end}}>Reports</span>
|
||||
</a>
|
||||
{{ end }}
|
||||
|
||||
{{ if .ShowVolunteerNav }}
|
||||
<a href="/volunteer/dashboard" class="text-sm font-medium {{if eq .ActiveSection "dashboard"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/volunteer/Addresses" class="text-sm font-medium {{if eq .ActiveSection "address"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
||||
Assigned Address
|
||||
</a>
|
||||
<!-- <a href="/volunteer/schedual" class="text-sm font-medium {{if eq .ActiveSection "schedual"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
||||
My Schedule
|
||||
</a> -->
|
||||
{{ end }}
|
||||
{{ if .ShowVolunteerNav }}
|
||||
<a href="/volunteer/dashboard" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "dashboard"}}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-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">
|
||||
<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>
|
||||
{{ end }}
|
||||
|
||||
<a href="/profile" class="text-sm font-medium {{if eq .ActiveSection "profile"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
||||
Profile
|
||||
</a>
|
||||
</div>
|
||||
<a href="/profile" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "profile"}}bg-blue-light text-blue-primary border-r-4 border-blue-primary pl-2 rounded-none{{else}}text-text-secondary hover:bg-gray-50 rounded-md{{end}} group">
|
||||
<i class="fas fa-user-circle w-5 {{if eq .ActiveSection "profile"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||
<span {{if eq .ActiveSection "profile"}}class="font-medium"{{end}}>Profile</span>
|
||||
</a>
|
||||
|
||||
<!-- Right: User Info and Actions -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- User Avatar and Name -->
|
||||
<div class="hidden sm:flex items-center space-x-3">
|
||||
<span class="text-sm text-gray-300">{{.UserName}}</span>
|
||||
<div class="w-8 h-8 bg-blue-500 flex items-center justify-center text-white font-medium rounded-full">
|
||||
{{slice .UserName 0 1}}
|
||||
</div>
|
||||
</div>
|
||||
<a href="/logout" class="flex items-center px-3 py-2.5 text-sm text-text-secondary hover:bg-gray-50 rounded-md group">
|
||||
<i class="fas fa-sign-out-alt w-5 text-gray-400 mr-3"></i>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<a href="/logout" class="hidden sm:flex items-center px-3 py-2 text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-600 rounded-md">
|
||||
<i class="fas fa-sign-out-alt mr-2"></i>
|
||||
Logout
|
||||
</a>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button @click="mobileMenuOpen = !mobileMenuOpen" class="md:hidden p-2 rounded-md text-gray-300 hover:text-white hover:bg-gray-600">
|
||||
<i class="fas fa-bars" x-show="!mobileMenuOpen"></i>
|
||||
<i class="fas fa-times" x-show="mobileMenuOpen"></i>
|
||||
<!-- Main Content Container -->
|
||||
<div class="main-content-container min-h-screen flex flex-col bg-custom-gray">
|
||||
<!-- Top Header -->
|
||||
<div class="bg-white border-b border-border-gray px-4 md:px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Hamburger (left aligned with consistent spacing) -->
|
||||
<div class="flex items-center">
|
||||
<button id="menu-toggle" class="md:hidden text-text-secondary hover:text-text-primary p-2 -ml-2" onclick="toggleSidebar()">
|
||||
<i class="fas fa-bars text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation Menu -->
|
||||
<div x-show="mobileMenuOpen"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="md:hidden border-t border-gray-600"
|
||||
@click.outside="mobileMenuOpen = false">
|
||||
|
||||
<div class="px-4 py-4 bg-gray-800">
|
||||
<!-- User Info Mobile -->
|
||||
<div class="flex items-center space-x-3 pb-4 mb-4 border-b border-gray-600">
|
||||
<div class="w-10 h-10 bg-blue-500 flex items-center justify-center text-white font-medium rounded-full">
|
||||
{{slice .UserName 0 1}}
|
||||
<!-- Right side -->
|
||||
<div class="flex items-center space-x-2 md:space-x-4">
|
||||
<!-- Dark mode -->
|
||||
<button class="text-text-secondary hover:text-text-primary p-2">
|
||||
<i class="fas fa-moon text-lg"></i>
|
||||
</button>
|
||||
|
||||
<!-- Profile (hover dropdown on desktop, click on mobile) -->
|
||||
<div class="relative group cursor-pointer">
|
||||
<div class="flex items-center space-x-2 md:space-x-3" onclick="toggleProfileMenu()">
|
||||
<span class="text-sm font-medium text-text-primary hidden sm:block">{{.UserName}}</span>
|
||||
<div class="w-8 h-8 rounded-full bg-blue-primary flex items-center justify-center text-white font-medium">
|
||||
{{slice .UserName 0 1}}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">{{.UserName}}</p>
|
||||
<p class="text-xs text-gray-400">Logged in</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation Links -->
|
||||
<div class="space-y-2">
|
||||
{{ if .ShowAdminNav }}
|
||||
<a href="/dashboard" @click="mobileMenuOpen = false"
|
||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "dashboard"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
||||
<i class="fas fa-chart-pie mr-3 w-4"></i>Dashboard
|
||||
</a>
|
||||
<a href="/volunteers" @click="mobileMenuOpen = false"
|
||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "volunteer"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
||||
<i class="fas fa-users mr-3 w-4"></i>Volunteers
|
||||
</a>
|
||||
<a href="/team_builder" @click="mobileMenuOpen = false"
|
||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "team_builder"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
||||
<i class="fas fa-user-friends mr-3 w-4"></i>Team Builder
|
||||
</a>
|
||||
<a href="/addresses" @click="mobileMenuOpen = false"
|
||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "address"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
||||
<i class="fas fa-map-marked-alt mr-3 w-4"></i>Addresses
|
||||
</a>
|
||||
<a href="/posts" @click="mobileMenuOpen = false"
|
||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "post"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
||||
<i class="fas fa-blog mr-3 w-4"></i>Posts
|
||||
</a>
|
||||
<a href="/reports" @click="mobileMenuOpen = false"
|
||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "report"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
||||
<i class="fas fa-table mr-3 w-4"></i>Reports
|
||||
</a>
|
||||
{{ end }}
|
||||
|
||||
{{ if .ShowVolunteerNav }}
|
||||
<a href="/volunteer/dashboard" @click="mobileMenuOpen = false"
|
||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "dashboard"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
||||
<i class="fas fa-chart-pie mr-3 w-4"></i>Dashboard
|
||||
</a>
|
||||
<a href="/volunteer/Addresses" @click="mobileMenuOpen = false"
|
||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "address"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
||||
<i class="fas fa-home mr-3 w-4"></i>Assigned Address
|
||||
</a>
|
||||
<a href="/volunteer/schedual" @click="mobileMenuOpen = false"
|
||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "schedual"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
||||
<i class="fas fa-calendar-alt mr-3 w-4"></i>My Schedule
|
||||
</a>
|
||||
{{ end }}
|
||||
|
||||
<a href="/profile" @click="mobileMenuOpen = false"
|
||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "profile"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
||||
<i class="fas fa-user-circle mr-3 w-4"></i>Profile
|
||||
</a>
|
||||
|
||||
<!-- Logout for Mobile -->
|
||||
<div class="border-t border-gray-600 pt-2 mt-4">
|
||||
<a href="/logout" class="block px-3 py-2 rounded-md text-base font-medium text-gray-300 hover:text-white hover:bg-gray-700">
|
||||
<i class="fas fa-sign-out-alt mr-3 w-4"></i>Logout
|
||||
</a>
|
||||
<!-- Dropdown -->
|
||||
<div id="profile-menu" class="absolute right-0 mt-2 w-40 bg-white border border-border-gray rounded-md shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
||||
<a href="/profile" class="block px-4 py-2 text-sm text-text-primary hover:bg-gray-100">Profile</a>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-text-primary hover:bg-gray-100">Settings</a>
|
||||
<a href="/logout" class="block px-4 py-2 text-sm text-red-600 hover:bg-gray-100">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-1 mt-14">
|
||||
<!--sm:px-4 lg:px-6-->
|
||||
<div class="max-w-9xl mx-auto overflow-hidden ">
|
||||
<!-- Page Content -->
|
||||
<div class="flex-1">
|
||||
{{ template "content" . }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{else}}
|
||||
<!-- Landing Page (unchanged) -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Linq - Poll System</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div class="min-h-screen" x-data="{ mobileMenuOpen: false }">
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-gray-700 border-b border-gray-600">
|
||||
<div class="max-w-7xl mx-auto px-6">
|
||||
<div class="flex justify-between items-center h-14">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="../../static/icon-512.png" alt="Logo" class="w-8 h-8"/>
|
||||
<span class="text-xl font-semibold text-white">Linq</span>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
<a href="#home" class="text-gray-300 hover:text-white py-3 px-1">Home</a>
|
||||
<a href="#products" class="text-gray-300 hover:text-white py-3 px-1">Products</a>
|
||||
<a href="#about" class="text-gray-300 hover:text-white py-3 px-1">About</a>
|
||||
</div>
|
||||
|
||||
<!-- Auth Buttons -->
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
<button onclick="openLoginModal()" class="px-4 py-2 text-gray-300 hover:text-white">
|
||||
Sign In
|
||||
</button>
|
||||
<button onclick="openRegisterModal()" class="px-6 py-2 bg-blue-500 text-white hover:bg-blue-600 rounded">
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button @click="mobileMenuOpen = !mobileMenuOpen" class="md:hidden p-2 text-gray-300 hover:text-white hover:bg-gray-600 rounded-md">
|
||||
<i class="fas fa-bars" x-show="!mobileMenuOpen"></i>
|
||||
<i class="fas fa-times" x-show="mobileMenuOpen"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Split Screen Login/Register Page -->
|
||||
<div class="min-h-screen flex" x-data="{ isLogin: true }">
|
||||
|
||||
<!-- Left Side - Image -->
|
||||
<div class="hidden lg:flex flex-1 relative overflow-hidden">
|
||||
<!-- Background overlay for better text readability -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-blue-700/40 z-10"></div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div x-show="mobileMenuOpen" class="md:hidden border-t border-gray-600 bg-gray-800">
|
||||
<div class="px-6 py-4 space-y-3">
|
||||
<a href="#home" @click="mobileMenuOpen = false" class="block py-2 text-gray-300 hover:text-white">Home</a>
|
||||
<a href="#products" @click="mobileMenuOpen = false" class="block py-2 text-gray-300 hover:text-white">Products</a>
|
||||
<a href="#about" @click="mobileMenuOpen = false" class="block py-2 text-gray-300 hover:text-white">About</a>
|
||||
<div class="border-t border-gray-600 pt-3 space-y-2">
|
||||
<button onclick="openLoginModal(); document.querySelector('[x-data]').__x.$data.mobileMenuOpen = false"
|
||||
class="block w-full text-left py-2 text-gray-300 hover:text-white">
|
||||
Sign In
|
||||
</button>
|
||||
<button onclick="openRegisterModal(); document.querySelector('[x-data]').__x.$data.mobileMenuOpen = false"
|
||||
class="block w-full py-2 bg-blue-500 text-white hover:bg-blue-600 rounded">
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero -->
|
||||
<section id="home" class="pt-40 pb-16 px-6 min-h-[600px]">
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<h1 class="text-4xl sm:text-5xl font-bold text-gray-900 mb-6">
|
||||
Simple Polling
|
||||
<span class="text-blue-600">Management</span>
|
||||
</h1>
|
||||
<!-- Background Image -->
|
||||
<img src="../../static/feature-mobile1.jpg" alt="Welcome to Poll System" class="object-cover w-full h-full"/>
|
||||
|
||||
<p class="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||
Manage volunteers and track polling operations efficiently.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-center gap-4">
|
||||
<button onclick="openRegisterModal()" class="px-8 py-3 bg-blue-600 text-white hover:bg-blue-700 font-medium rounded">
|
||||
Start Free
|
||||
</button>
|
||||
<button onclick="openLoginModal()" class="px-8 py-3 border border-gray-300 text-gray-700 hover:bg-gray-50 font-medium rounded">
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Products Section -->
|
||||
<section id="products" class="py-16 bg-white min-h-[600px]">
|
||||
<div class="max-w-6xl mx-auto px-6">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-4">Our Products</h2>
|
||||
<p class="text-lg text-gray-600">Tools built for modern polling operations</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<div class="p-6 border border-gray-200 rounded-lg">
|
||||
<img src="../../static/icon-512.png" alt="Poll Manager" class="w-12 h-12 mb-4"/>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-3">Poll Manager</h3>
|
||||
<p class="text-gray-600 mb-4">Complete polling campaign management with real-time tracking and coordination tools.</p>
|
||||
<ul class="text-sm text-gray-500 space-y-1">
|
||||
<li>• Volunteer coordination</li>
|
||||
<li>• Address management</li>
|
||||
<li>• Progress tracking</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border border-gray-200 rounded-lg">
|
||||
<img src="../../static/icon-512.png" alt="Analytics Suite" class="w-12 h-12 mb-4"/>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-3">Analytics Suite</h3>
|
||||
<p class="text-gray-600 mb-4">Advanced reporting and analytics dashboard for data-driven insights.</p>
|
||||
<ul class="text-sm text-gray-500 space-y-1">
|
||||
<li>• Performance metrics</li>
|
||||
<li>• Custom reports</li>
|
||||
<li>• Data visualization</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border border-gray-200 rounded-lg">
|
||||
<img src="../../static/icon-512.png" alt="Team Builder" class="w-12 h-12 mb-4"/>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-3">Team Builder</h3>
|
||||
<p class="text-gray-600 mb-4">Organize teams and assign roles with automated scheduling and notifications.</p>
|
||||
<ul class="text-sm text-gray-500 space-y-1">
|
||||
<li>• Role assignment</li>
|
||||
<li>• Schedule management</li>
|
||||
<li>• Team communication</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About -->
|
||||
<section id="about" class="py-16 bg-gray-50 min-h-[600px]">
|
||||
<div class="max-w-6xl mx-auto px-6">
|
||||
<div class="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-6">About Linq</h2>
|
||||
<p class="text-lg text-gray-600 mb-6">
|
||||
Built for organizations that need efficient polling management.
|
||||
Our platform simplifies volunteer coordination and progress tracking.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-3 gap-6 mb-8">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-900">500+</div>
|
||||
<div class="text-sm text-gray-500">Volunteers</div>
|
||||
</div>
|
||||
<!-- <div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-900">50+</div>
|
||||
<div class="text-sm text-gray-500">Organizations</div>
|
||||
</div> -->
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-900">400,000+</div>
|
||||
<div class="text-sm text-gray-500">Addresses</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-8 rounded-lg border border-gray-200">
|
||||
<img src="../../static/feature-mobile4.jpg" alt="Dashboard Preview" class="w-full h-48 object-cover rounded mb-6 bg-gray-100"/>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-3">Simple Dashboard</h3>
|
||||
<p class="text-gray-600">
|
||||
Everything you need in one place. Track progress, manage teams, and generate reports.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white border-t border-gray-200 py-8">
|
||||
<div class="max-w-6xl mx-auto px-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Logo and branding overlay -->
|
||||
<div class="absolute top-8 left-8 z-20 flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
|
||||
<img src="../../static/icon-512.png" alt="Logo" class="w-6 h-6"/>
|
||||
<span class="font-semibold text-gray-900">Linq</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 text-sm text-gray-500">
|
||||
<a href="#" class="hover:text-gray-900">Privacy</a>
|
||||
<a href="#" class="hover:text-gray-900">Terms</a>
|
||||
<a href="#" class="hover:text-gray-900">Contact</a>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500">© 2025 Linq. All rights reserved.</p>
|
||||
<span class="text-2xl font-bold text-white">Linq</span>
|
||||
</div>
|
||||
|
||||
<!-- Welcome text overlay -->
|
||||
<div class="absolute bottom-8 left-8 right-8 z-20 text-white">
|
||||
<h1 class="text-4xl font-bold mb-4">Welcome to Poll System</h1>
|
||||
<p class="text-xl opacity-90">Streamline your polling operations with our comprehensive management platform</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<!-- Login Modal -->
|
||||
<div id="loginModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50 p-4">
|
||||
<div class="bg-white shadow-2xl max-w-4xl w-full overflow-hidden rounded-lg">
|
||||
<div class="flex flex-col lg:flex-row min-h-[500px]">
|
||||
<!-- Left Side - Image -->
|
||||
<div class="hidden lg:flex flex-1 bg-gradient-to-br from-blue-500 to-blue-700 flex-col items-center justify-center">
|
||||
<img src="../../static/feature-mobile2.jpg" alt="Welcome Image" class="object-cover h-full rounded-lg shadow-lg">
|
||||
</div>
|
||||
<!-- Right Side - Form -->
|
||||
<div class="flex-1 p-6 sm:p-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-xl sm:text-2xl font-bold text-gray-900">Sign In</h3>
|
||||
<button onclick="closeLoginModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
|
||||
<!-- Right Side - Login/Register Forms -->
|
||||
<div class="flex-1 flex items-center justify-center p-6 lg:p-12 bg-white">
|
||||
<div class="w-full max-w-md">
|
||||
|
||||
<!-- Mobile Logo (visible only on small screens) -->
|
||||
<div class="lg:hidden flex items-center justify-center gap-3 mb-8">
|
||||
<div class="w-10 h-10 bg-blue-primary rounded-full flex items-center justify-center">
|
||||
<img src="../../static/icon-512.png" alt="Logo" class="w-6 h-6"/>
|
||||
</div>
|
||||
<form method="POST" action="/login" class="space-y-6">
|
||||
<div>
|
||||
<label for="login_email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||
<input type="email" name="email" id="login_email" required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<span class="text-2xl font-bold text-text-primary">Linq</span>
|
||||
</div>
|
||||
|
||||
<!-- Toggle Buttons -->
|
||||
<div class="flex justify-center gap-1 mb-8 p-1 bg-gray-100 rounded-lg">
|
||||
<button @click="isLogin = true" :class="isLogin ? 'bg-white text-blue-primary shadow-sm' : 'text-gray-600'" class="flex-1 px-4 py-2 rounded-md font-medium transition-all duration-200">
|
||||
Sign In
|
||||
</button>
|
||||
<button @click="isLogin = false" :class="!isLogin ? 'bg-white text-blue-primary shadow-sm' : 'text-gray-600'" class="flex-1 px-4 py-2 rounded-md font-medium transition-all duration-200">
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div x-show="isLogin" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 transform translate-x-4" x-transition:enter-end="opacity-100 transform translate-x-0">
|
||||
<form action="/login" method="POST" class="space-y-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-3xl font-bold text-text-primary">Welcome back</h2>
|
||||
<p class="text-text-secondary mt-2">Please sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="login_password" class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<input type="password" name="password" id="login_password" required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<label for="login_email" class="block text-sm font-medium text-text-primary mb-2">Email Address</label>
|
||||
<input type="email"
|
||||
id="login_email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="Enter your email"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||
</div>
|
||||
<button type="submit" class="w-full bg-blue-600 text-white py-3 hover:bg-blue-700 font-medium transition-colors rounded">
|
||||
|
||||
<div>
|
||||
<label for="login_password" class="block text-sm font-medium text-text-primary mb-2">Password</label>
|
||||
<input type="password"
|
||||
id="login_password"
|
||||
name="password"
|
||||
required
|
||||
placeholder="Enter your password"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="h-4 w-4 text-blue-primary focus:ring-blue-primary border-gray-300 rounded">
|
||||
<span class="ml-2 text-sm text-text-secondary">Remember me</span>
|
||||
</label>
|
||||
<a href="#" class="text-sm text-blue-primary hover:text-blue-600">Forgot password?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full bg-blue-primary text-white py-3 rounded-lg hover:bg-blue-600 focus:ring-2 focus:ring-blue-primary focus:ring-offset-2 transition-colors font-medium">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
<p class="text-center text-sm text-gray-600 mt-6">
|
||||
Don't have an account?
|
||||
<button onclick="switchToRegister()" class="text-blue-600 hover:text-blue-700 font-medium">Sign up</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Register Modal -->
|
||||
<div id="registerModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50 p-4">
|
||||
<div class="bg-white shadow-2xl max-w-4xl w-full overflow-hidden rounded-lg">
|
||||
<div class="flex flex-col lg:flex-row min-h-[600px]">
|
||||
<!-- Left Side - Image -->
|
||||
<div class="hidden lg:flex flex-1 bg-gradient-to-br from-blue-600 to-blue-800 flex-col items-center justify-center">
|
||||
<img src="../../static/feature-mobile1.jpg" alt="Welcome Image" class="object-cover h-full rounded-lg shadow-lg">
|
||||
</div>
|
||||
|
||||
<!-- Right Side - Form -->
|
||||
<div class="flex-1 p-6 sm:p-8 overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-xl sm:text-2xl font-bold text-gray-900">Create Account</h3>
|
||||
<button onclick="closeRegisterModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/register" class="space-y-4">
|
||||
<!-- Register Form -->
|
||||
<div x-show="!isLogin" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 transform translate-x-4" x-transition:enter-end="opacity-100 transform translate-x-0">
|
||||
<form action="/register" method="POST" class="space-y-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-3xl font-bold text-text-primary">Create Account</h2>
|
||||
<p class="text-text-secondary mt-2">Join our polling platform today</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="first_name" class="block text-sm font-medium text-gray-700 mb-1">First Name</label>
|
||||
<input type="text" name="first_name" id="first_name" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<label class="block text-sm font-medium text-text-primary mb-2">First Name</label>
|
||||
<input type="text"
|
||||
name="first_name"
|
||||
required
|
||||
placeholder="First Name"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="last_name" class="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
|
||||
<input type="text" name="last_name" id="last_name" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<label class="block text-sm font-medium text-text-primary mb-2">Last Name</label>
|
||||
<input type="text"
|
||||
name="last_name"
|
||||
required
|
||||
placeholder="Last Name"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="register_email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" name="email" id="register_email" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<label class="block text-sm font-medium text-text-primary mb-2">Email Address</label>
|
||||
<input type="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="Enter your email"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
||||
<input type="tel" name="phone" id="phone"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<label class="block text-sm font-medium text-text-primary mb-2">Phone</label>
|
||||
<input type="tel"
|
||||
name="phone"
|
||||
required
|
||||
placeholder="Phone number"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="role" class="block text-sm font-medium text-gray-700 mb-1">Role</label>
|
||||
<select name="role" id="role" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
onchange="toggleAdminCodeField()">
|
||||
<option value="">Select role</option>
|
||||
<label class="block text-sm font-medium text-text-primary mb-2">Role</label>
|
||||
<select name="role"
|
||||
required
|
||||
onchange="toggleAdminCodeField()"
|
||||
id="role"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors">
|
||||
<option value="">Select Role</option>
|
||||
<option value="1">Admin</option>
|
||||
<option value="3">Volunteer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Admin Code field (hidden by default) -->
|
||||
|
||||
<!-- Admin/Team Leader Code Field (hidden by default) -->
|
||||
<div id="adminCodeField" class="hidden">
|
||||
<label for="admin_code" class="block text-sm font-medium text-gray-700 mb-1">Admin Code</label>
|
||||
<input type="text" name="admin_code" id="admin_code"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="Enter your admin's code">
|
||||
<label class="block text-sm font-medium text-text-primary mb-2">Access Code</label>
|
||||
<input type="password"
|
||||
name="admin_code"
|
||||
placeholder="Enter access code"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||
<p class="text-xs text-text-secondary mt-1">Required for Admin and Team Leader roles</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label for="register_password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input type="password" name="password" id="register_password" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<label class="block text-sm font-medium text-text-primary mb-2">Password</label>
|
||||
<input type="password"
|
||||
name="password"
|
||||
required
|
||||
placeholder="Create a password"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||
</div>
|
||||
<button type="submit" class="w-full bg-blue-600 text-white py-3 hover:bg-blue-700 font-medium transition-colors rounded mt-6">
|
||||
|
||||
<button type="submit" class="w-full bg-blue-primary text-white py-3 rounded-lg hover:bg-blue-600 focus:ring-2 focus:ring-blue-primary focus:ring-offset-2 transition-colors font-medium">
|
||||
Create Account
|
||||
</button>
|
||||
</form>
|
||||
<p class="text-center text-sm text-gray-600 mt-4">
|
||||
Already have an account?
|
||||
<button onclick="switchToLogin()" class="text-blue-600 hover:text-blue-700 font-medium">Sign in</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
// Initialize Alpine.js data for mobile menu
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('sidebar', () => ({
|
||||
open: false
|
||||
}));
|
||||
});
|
||||
|
||||
// Smooth scrolling for navigation links
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const links = document.querySelectorAll('a[href^="#"]');
|
||||
// Sidebar functionality for authenticated users
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||
|
||||
for (const link of links) {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const targetId = this.getAttribute('href').substring(1);
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
if (targetElement) {
|
||||
const offsetTop = targetElement.offsetTop - 80; // Account for fixed navbar
|
||||
window.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function openLoginModal() {
|
||||
document.getElementById('loginModal').classList.remove('hidden');
|
||||
document.getElementById('loginModal').classList.add('flex');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeLoginModal() {
|
||||
document.getElementById('loginModal').classList.add('hidden');
|
||||
document.getElementById('loginModal').classList.remove('flex');
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
function openRegisterModal() {
|
||||
document.getElementById('registerModal').classList.remove('hidden');
|
||||
document.getElementById('registerModal').classList.add('flex');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeRegisterModal() {
|
||||
document.getElementById('registerModal').classList.add('hidden');
|
||||
document.getElementById('registerModal').classList.remove('flex');
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
function switchToRegister() {
|
||||
closeLoginModal();
|
||||
setTimeout(() => openRegisterModal(), 100);
|
||||
}
|
||||
|
||||
function switchToLogin() {
|
||||
closeRegisterModal();
|
||||
setTimeout(() => openLoginModal(), 100);
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.onclick = function(event) {
|
||||
const loginModal = document.getElementById('loginModal');
|
||||
const registerModal = document.getElementById('registerModal');
|
||||
|
||||
if (event.target === loginModal) {
|
||||
closeLoginModal();
|
||||
}
|
||||
if (event.target === registerModal) {
|
||||
closeRegisterModal();
|
||||
if (sidebar && sidebarOverlay) {
|
||||
sidebar.classList.toggle('active');
|
||||
sidebarOverlay.classList.toggle('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Profile menu toggle
|
||||
function toggleProfileMenu() {
|
||||
const menu = document.getElementById('profile-menu');
|
||||
if (menu) {
|
||||
menu.classList.toggle('opacity-0');
|
||||
menu.classList.toggle('invisible');
|
||||
menu.classList.toggle('opacity-100');
|
||||
menu.classList.toggle('visible');
|
||||
}
|
||||
}
|
||||
|
||||
// Admin code field toggle for registration
|
||||
function toggleAdminCodeField() {
|
||||
const role = document.getElementById("role").value;
|
||||
const role = document.getElementById("role");
|
||||
const field = document.getElementById("adminCodeField");
|
||||
field.classList.toggle("hidden", role !== "3" && role !== "2"); // show only if Volunteer or Team Leader
|
||||
|
||||
if (role && field) {
|
||||
const roleValue = role.value;
|
||||
if (roleValue === "1") { // Admin or Team Leader
|
||||
field.classList.add("hidden");
|
||||
} else {
|
||||
field.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle escape key
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') {
|
||||
closeLoginModal();
|
||||
closeRegisterModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside (for landing page)
|
||||
document.addEventListener('click', function(event) {
|
||||
const mobileMenuButton = event.target.closest('[\\@click="mobileMenuOpen = !mobileMenuOpen"]');
|
||||
const mobileMenu = event.target.closest('.md\\:hidden .bg-white');
|
||||
|
||||
if (!mobileMenuButton && !mobileMenu) {
|
||||
// This will be handled by Alpine.js automatically
|
||||
// Close sidebar on ESC key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||
const body = document.body;
|
||||
|
||||
if (sidebar && sidebarOverlay && sidebar.classList.contains('active')) {
|
||||
sidebar.classList.remove('active');
|
||||
sidebarOverlay.classList.remove('active');
|
||||
body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle window resize to ensure proper mobile behavior
|
||||
window.addEventListener('resize', function() {
|
||||
if (window.innerWidth >= 1024) {
|
||||
// Close mobile menus on desktop
|
||||
const sidebarComponent = document.querySelector('[x-data]');
|
||||
if (sidebarComponent && sidebarComponent.__x) {
|
||||
sidebarComponent.__x.$data.sidebarOpen = false;
|
||||
sidebarComponent.__x.$data.mobileMenuOpen = false;
|
||||
if (window.innerWidth >= 768) {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||
const body = document.body;
|
||||
|
||||
if (sidebar && sidebarOverlay) {
|
||||
sidebar.classList.remove('active');
|
||||
sidebarOverlay.classList.remove('active');
|
||||
body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Close sidebar when clicking on overlay
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||
if (sidebarOverlay) {
|
||||
sidebarOverlay.addEventListener('click', function() {
|
||||
toggleSidebar();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{{ define "content" }}
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<!-- Header -->
|
||||
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Create Post Form -->
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
>
|
||||
<option value="">Select Category</option>
|
||||
<option value="users" {{if eq .Category "users"}}selected{{end}}>Users & Teams</option>
|
||||
<option value="addresses" {{if eq .Category "addresses"}}selected{{end}}>Addresses</option>
|
||||
<option value="address" {{if eq .Category "address"}}selected{{end}}>Addresses</option>
|
||||
<option value="appointments" {{if eq .Category "appointments"}}selected{{end}}>Appointments</option>
|
||||
<option value="polls" {{if eq .Category "polls"}}selected{{end}}>Polls</option>
|
||||
<option value="responses" {{if eq .Category "responses"}}selected{{end}}>Poll Responses</option>
|
||||
<option value="availability" {{if eq .Category "availability"}}selected{{end}}>Availability</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -41,30 +40,15 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date Range (optional) -->
|
||||
<!-- 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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
@@ -76,16 +60,10 @@
|
||||
<div class="text-gray-600">
|
||||
<span>{{.Result.Count}} results</span>
|
||||
</div>
|
||||
<button
|
||||
onclick="exportResults()"
|
||||
class="px-3 py-1.5 bg-green-600 text-white hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<button onclick="exportResults()" class="px-3 py-1.5 bg-green-600 text-white hover:bg-green-700 transition-colors">
|
||||
<i class="fas fa-download mr-1"></i>Export CSV
|
||||
</button>
|
||||
<button
|
||||
onclick="printReport()"
|
||||
class="px-3 py-1.5 bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<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>
|
||||
@@ -93,43 +71,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
{{if .Result}}
|
||||
{{if .Result.Error}}
|
||||
<!-- Error State -->
|
||||
<div class="p-6">
|
||||
<div class="bg-red-50 border border-red-200 p-6">
|
||||
<div class="flex items-start">
|
||||
<div class="w-10 h-10 bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-red-600"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-red-800 mb-2">Report Error</h3>
|
||||
<p class="text-red-700">{{.Result.Error}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-red-800 mb-2">Report Error</h3>
|
||||
<p class="text-red-700">{{.Result.Error}}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- Report Header -->
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">{{.ReportTitle}}</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">{{.ReportDescription}}</p>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
Generated: {{.GeneratedAt}}
|
||||
</div>
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">{{.ReportTitle}}</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">{{.ReportDescription}}</p>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Generated: {{.GeneratedAt}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Table -->
|
||||
{{if gt .Result.Count 0}}
|
||||
<div class="flex-1 overflow-x-auto overflow-y-auto bg-white">
|
||||
<table class="w-full divide-gray-200 text-sm table-auto">
|
||||
<thead class="bg-gray-50 divide-gray-200 sticky top-0">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr class="text-left text-gray-700 font-medium border-b border-gray-200">
|
||||
{{range .Result.Columns}}
|
||||
<th class="px-6 py-3 whitespace-nowrap">{{formatColumnName .}}</th>
|
||||
@@ -148,7 +114,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats (if available) -->
|
||||
<!-- 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>
|
||||
@@ -164,187 +130,75 @@
|
||||
{{end}}
|
||||
|
||||
{{else}}
|
||||
<!-- No Results State -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-gray-100 flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-chart-bar text-gray-400 text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No Data Found</h3>
|
||||
<p class="text-gray-500">No results match your selected criteria</p>
|
||||
</div>
|
||||
<p class="text-gray-500">No results match your selected criteria</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
<!-- Welcome State -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center py-12 max-w-4xl mx-auto px-6">
|
||||
<div class="mb-8">
|
||||
<div class="w-20 h-20 bg-gradient-to-br from-purple-600 to-purple-700 flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-chart-line text-white text-2xl"></i>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Campaign Reports</h1>
|
||||
<p class="text-gray-600 text-lg">Generate detailed reports across all your campaign data</p>
|
||||
</div>
|
||||
|
||||
<!-- Report Categories Overview -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-left">
|
||||
<div class="p-4 bg-white border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all duration-200">
|
||||
<div class="font-medium text-gray-900 text-sm mb-1">Users & Teams</div>
|
||||
<div class="text-xs text-gray-500">Volunteer performance, team stats, role distribution</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-white border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all duration-200">
|
||||
<div class="font-medium text-gray-900 text-sm mb-1">Address Reports</div>
|
||||
<div class="text-xs text-gray-500">Coverage areas, visit status, geographical insights</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-white border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all duration-200">
|
||||
<div class="font-medium text-gray-900 text-sm mb-1">Appointments</div>
|
||||
<div class="text-xs text-gray-500">Schedule analysis, completion rates, time trends</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-white border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all duration-200">
|
||||
<div class="font-medium text-gray-900 text-sm mb-1">Poll Analytics</div>
|
||||
<div class="text-xs text-gray-500">Response rates, donation tracking, engagement metrics</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-sm text-gray-500">
|
||||
Select a category above to see available reports
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-600">Select a category and report to generate results</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Square corners across UI */
|
||||
* {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
input, select, button {
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Print-specific styles */
|
||||
body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.bg-gray-50 {
|
||||
background: white !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Report definitions for each category
|
||||
const reportDefinitions = {
|
||||
users: [
|
||||
{ id: 'users_by_role', name: 'Users by Role' },
|
||||
{ id: 'volunteer_activity', name: 'Volunteer Activity Summary' },
|
||||
{ id: 'team_performance', name: 'Team Performance Report' },
|
||||
{ id: 'admin_workload', name: 'Admin Workload Analysis' },
|
||||
{ id: 'inactive_users', name: 'Inactive Users Report' }
|
||||
{ id: 'participation', name: 'Volunteer Participation Rate' },
|
||||
{ id: 'top_performers', name: 'Top Performing Volunteers' },
|
||||
{ id: 'efficiency', name: 'Response-to-Donation Ratio' },
|
||||
{ id: 'coverage', name: 'User Address Coverage' }
|
||||
],
|
||||
addresses: [
|
||||
{ id: 'coverage_by_area', name: 'Coverage by Area' },
|
||||
{ id: 'visits_by_postal', name: 'Visits by Postal Code' },
|
||||
{ id: 'unvisited_addresses', name: 'Unvisited Addresses' },
|
||||
{ id: 'donations_by_location', name: 'Donations by Location' },
|
||||
{ id: 'address_validation_status', name: 'Address Validation Status' }
|
||||
address: [
|
||||
{ id: 'responses_by_address', name: 'Total Responses by Address' },
|
||||
{ id: 'donations_by_address', name: 'Total Donations by Address' },
|
||||
{ id: 'street_breakdown', name: 'Street-Level Breakdown' },
|
||||
{ id: 'quadrant_summary', name: 'Quadrant Summary' }
|
||||
],
|
||||
appointments: [
|
||||
{ id: 'appointments_by_day', name: 'Appointments by Day' },
|
||||
{ id: 'completion_rates', name: 'Completion Rates' },
|
||||
{ id: 'volunteer_schedules', name: 'Volunteer Schedules' },
|
||||
{ id: 'missed_appointments', name: 'Missed Appointments' },
|
||||
{ id: 'peak_hours', name: 'Peak Activity Hours' }
|
||||
{ id: 'upcoming', name: 'Upcoming Appointments' },
|
||||
{ id: 'completion', name: 'Appointments Completion Rate' },
|
||||
{ id: 'geo_distribution', name: 'Appointments by Quadrant' },
|
||||
{ id: 'lead_time', name: 'Average Lead Time' }
|
||||
],
|
||||
polls: [
|
||||
{ id: 'poll_creation_stats', name: 'Poll Creation Statistics' },
|
||||
{ id: 'donation_analysis', name: 'Donation Analysis' },
|
||||
{ id: 'active_vs_inactive', name: 'Active vs Inactive Polls' },
|
||||
{ id: 'poll_trends', name: 'Poll Activity Trends' },
|
||||
{ id: 'creator_performance', name: 'Creator Performance' }
|
||||
],
|
||||
responses: [
|
||||
{ id: 'voter_status', name: 'Voter Status Report' },
|
||||
{ id: 'sign_requests', name: 'Sign Requests Summary' },
|
||||
{ id: 'feedback_analysis', name: 'Feedback Analysis' },
|
||||
{ id: 'response_trends', name: 'Response Trends' },
|
||||
{ id: 'repeat_voters', name: 'Repeat Voters Analysis' }
|
||||
{ id: 'distribution', name: 'Response Distribution' },
|
||||
{ id: 'average', name: 'Average Poll Response' },
|
||||
{ id: 'donations_by_poll', name: 'Donations by Poll' },
|
||||
{ id: 'correlation', name: 'Response-to-Donation Correlation' }
|
||||
],
|
||||
availability: [
|
||||
{ id: 'volunteer_availability', name: 'Volunteer Availability' },
|
||||
{ id: 'peak_availability', name: 'Peak Availability Times' },
|
||||
{ id: 'coverage_gaps', name: 'Coverage Gaps' },
|
||||
{ id: 'schedule_conflicts', name: 'Schedule Conflicts' }
|
||||
{ id: 'by_date', name: 'Volunteer Availability by Date' },
|
||||
{ id: 'gaps', name: 'Coverage Gaps' },
|
||||
{ id: 'overlaps', name: 'Volunteer Overlaps' },
|
||||
{ id: 'fulfillment', name: 'Volunteer Fulfillment' }
|
||||
]
|
||||
};
|
||||
|
||||
// Update reports dropdown when category changes
|
||||
function updateReports() {
|
||||
const categorySelect = document.getElementById('category');
|
||||
const category = document.getElementById('category').value;
|
||||
const reportSelect = document.getElementById('report');
|
||||
const category = categorySelect.value;
|
||||
|
||||
// Clear existing options
|
||||
reportSelect.innerHTML = '<option value="">Select Report</option>';
|
||||
|
||||
if (category && reportDefinitions[category]) {
|
||||
reportDefinitions[category].forEach(report => {
|
||||
const option = document.createElement('option');
|
||||
option.value = report.id;
|
||||
option.textContent = report.name;
|
||||
reportSelect.appendChild(option);
|
||||
if (reportDefinitions[category]) {
|
||||
reportDefinitions[category].forEach(r => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = r.id;
|
||||
opt.textContent = r.name;
|
||||
reportSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export results
|
||||
function exportResults() {
|
||||
const form = document.querySelector('form');
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams(formData);
|
||||
const params = new URLSearchParams(new FormData(document.querySelector('form')));
|
||||
params.set('export', 'csv');
|
||||
window.location.href = `/reports/export?${params.toString()}`;
|
||||
window.location.href = `/reports?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Print report
|
||||
function printReport() {
|
||||
window.print();
|
||||
}
|
||||
function printReport() { window.print(); }
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
updateReports();
|
||||
|
||||
// Set default date range (last 30 days)
|
||||
const dateFrom = document.getElementById('date_from');
|
||||
const dateTo = document.getElementById('date_to');
|
||||
|
||||
if (!dateFrom.value) {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
dateFrom.value = thirtyDaysAgo.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (!dateTo.value) {
|
||||
dateTo.value = new Date().toISOString().split('T')[0];
|
||||
}
|
||||
});
|
||||
document.addEventListener("DOMContentLoaded", updateReports);
|
||||
</script>
|
||||
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
208
app/internal/templates/team_builder.html
Normal file
208
app/internal/templates/team_builder.html
Normal file
@@ -0,0 +1,208 @@
|
||||
{{ define "content" }}
|
||||
<div class="flex-1 flex flex-col overflow-hidden ">
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 p-4 md:p-6 overflow-auto">
|
||||
<div class="space-y-6">
|
||||
{{ range .TeamLeads }}
|
||||
{{ $teamLeadID := .ID }}
|
||||
<!-- Team Lead Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<!-- Team Lead Header -->
|
||||
<div class="bg-gray-50 border-b border-gray-200 px-6 py-4">
|
||||
<div class="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
|
||||
<!-- Team Lead Info -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-user-tie text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ .Name }}</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ if .Volunteers }}
|
||||
{{ len .Volunteers }} volunteer{{ if ne (len .Volunteers) 1 }}s{{ end }} assigned
|
||||
{{ else }}
|
||||
No volunteers assigned
|
||||
{{ end }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Volunteer Form -->
|
||||
{{ if $.UnassignedVolunteers }}
|
||||
<form action="/team_builder" method="POST" class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 w-full lg:w-auto">
|
||||
<input type="hidden" name="team_lead_id" value="{{ .ID }}" />
|
||||
<select
|
||||
name="volunteer_id"
|
||||
class="px-4 py-2 text-sm 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 min-w-0 flex-1 sm:w-64"
|
||||
required
|
||||
>
|
||||
<option value="">Select volunteer to assign</option>
|
||||
{{ range $.UnassignedVolunteers }}
|
||||
<option value="{{ .ID }}">{{ .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex items-center justify-center px-4 py-2 bg-blue-500 text-white text-sm hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium rounded-lg transition-colors whitespace-nowrap"
|
||||
>
|
||||
<i class="fas fa-plus mr-2"></i> Add Volunteer
|
||||
</button>
|
||||
</form>
|
||||
{{ else }}
|
||||
<div class="text-sm text-gray-500 bg-gray-100 px-4 py-2 rounded-lg">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
All volunteers have been assigned
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Volunteer List -->
|
||||
<div class="hidden md:block">
|
||||
{{ if .Volunteers }}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="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">
|
||||
Volunteer
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</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">
|
||||
{{ range .Volunteers }}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-user text-gray-500 text-sm"></i>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-900">{{ .Name }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||
<i class="fas fa-check mr-1"></i> Assigned
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<form action="/team_builder/remove_volunteer" method="POST" class="inline-block">
|
||||
<input type="hidden" name="team_lead_id" value="{{ $teamLeadID }}" />
|
||||
<input type="hidden" name="volunteer_id" value="{{ .ID }}" />
|
||||
<button
|
||||
type="submit"
|
||||
class="text-red-400 hover:text-red-600 p-1"
|
||||
title="Remove {{ .Name }} from team"
|
||||
onclick="return confirm('Remove {{ .Name }} from this team?')"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="px-6 py-8 text-center">
|
||||
<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 assigned</h3>
|
||||
<p class="text-gray-500">Use the form above to assign volunteers to this team lead.</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Volunteer Cards -->
|
||||
<div class="md:hidden">
|
||||
{{ if .Volunteers }}
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{ range .Volunteers }}
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<div class="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-user text-gray-500"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 truncate">{{ .Name }}</div>
|
||||
<div class="flex items-center mt-1">
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||
<i class="fas fa-check mr-1"></i> Assigned
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form action="/team_builder/remove_volunteer" method="POST" class="ml-4">
|
||||
<input type="hidden" name="team_lead_id" value="{{ $teamLeadID }}" />
|
||||
<input type="hidden" name="volunteer_id" value="{{ .ID }}" />
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3 py-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors text-sm font-medium"
|
||||
onclick="return confirm('Remove {{ .Name }} from this team?')"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i> Remove
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="px-6 py-8 text-center">
|
||||
<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 assigned</h3>
|
||||
<p class="text-gray-500 text-sm">Use the form above to assign volunteers to this team lead.</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<!-- No Team Leads State -->
|
||||
{{ if not .TeamLeads }}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-12 text-center">
|
||||
<div class="text-gray-400 mb-4">
|
||||
<i class="fas fa-user-tie text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-gray-900 mb-2">No Team Leads Available</h3>
|
||||
<p class="text-gray-500">Add team leads to start building teams and assigning volunteers.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Consistent styling with addresses component */
|
||||
input, select, button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
button {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
/* Ensure proper responsive behavior */
|
||||
@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 }}
|
||||
@@ -1,102 +0,0 @@
|
||||
{{ define "content" }}
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Main Content -->
|
||||
<div class="p-6 space-y-6">
|
||||
{{range .TeamLeads}} {{ $teamLeadID := .ID }}
|
||||
<!-- store team lead ID -->
|
||||
|
||||
<div class="bg-white border border-gray-200 shadow-sm">
|
||||
<!-- Team Lead Header -->
|
||||
<div
|
||||
class="flex justify-between items-center px-4 py-3 border-b border-gray-200"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fas fa-user-tie text-blue-600"></i>
|
||||
<span class="font-semibold text-gray-900">{{.Name}}</span>
|
||||
</div>
|
||||
<form
|
||||
action="/team_builder"
|
||||
method="POST"
|
||||
class="flex items-center space-x-3"
|
||||
>
|
||||
<input type="hidden" name="team_lead_id" value="{{.ID}}" />
|
||||
|
||||
<select
|
||||
name="volunteer_id"
|
||||
class="px-3 py-2 border border-gray-300 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">--Select Volunteer--</option>
|
||||
{{range $.UnassignedVolunteers}}
|
||||
<option value="{{.ID}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
|
||||
>
|
||||
<i class="fas fa-plus mr-2"></i> Add
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Assigned Volunteers -->
|
||||
<div class="px-6 py-4">
|
||||
{{if .Volunteers}}
|
||||
<ul class="space-y-2">
|
||||
{{range .Volunteers}}
|
||||
<li
|
||||
class="flex items-center justify-between text-gray-800 border-b border-gray-200 py-2"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<i class="fas fa-user text-gray-500"></i>
|
||||
<span>{{.Name}}</span>
|
||||
</div>
|
||||
<form
|
||||
action="/team_builder/remove_volunteer"
|
||||
method="POST"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="team_lead_id"
|
||||
value="{{ $teamLeadID }}"
|
||||
/>
|
||||
<input type="hidden" name="volunteer_id" value="{{.ID}}" />
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Remove {{.Name}}"
|
||||
class="px-3 py-1 bg-red-600 text-white hover:bg-red-700 focus:outline-none focus:ring-1 focus:ring-red-500"
|
||||
>
|
||||
<i class="fas fa-times"></i> Remove
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="text-gray-500 italic">No volunteers assigned yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Square corners across UI */
|
||||
* {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
button {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
</style>
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user